diff --git a/.gitignore b/.gitignore index a87420c6..460f1f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ dist/ coverage.* +# compiled test binaries (go test -c) +*.test + .direnv diff --git a/IR.g4 b/IR.g4 new file mode 100644 index 00000000..7e5daf8c --- /dev/null +++ b/IR.g4 @@ -0,0 +1,82 @@ +grammar IR; + +// --- Parser rules --- + +program: line* EOF; + +line: labelMarker | instruction; + +labelMarker: LABEL; + +instruction + : dest '=' instrCall # instrWithDest + | instrCall # instrNoDest + | dest '=' const_ # constAssign + | dest '=' left=reg op=(PLUS | MINUS) right=reg # infixInstr + | left=reg op=(PLUS_EQ | MINUS_EQ) right=reg # compoundAssignInstr + ; + +dest + : reg # destReg + | '_' # destDiscard + | '[' regList ']' # destList + ; + +regList: reg (',' reg)*; + +instrCall: instrName '(' args ')'; + +instrName: IDENTIFIER ('<' typeName '>')?; + +typeName: TYPE_KEYWORD; + +args: (arg (',' arg)*)?; + +arg + : value # positionalArg + | IDENTIFIER ':' value # labeledArg + ; + +value + : reg # valReg + | LABEL # valLabel + | INT # valInt + | '[' regList ']' # valRegList + ; + +const_ + : STRING # constString + | INT # constInt + | BOOL # constBool + ; + +reg: REG; + +// --- Lexer rules --- + +WS: [ \t]+ -> skip; +NEWLINE: [\r\n]+ -> skip; + +// Must come before IDENTIFIER so keywords are not swallowed +TYPE_KEYWORD: 'int' | 'str' | 'portion' | 'monetary'; +BOOL: 'true' | 'false'; + +REG: '$' [a-zA-Z_] [a-zA-Z0-9_]*; +LABEL: '#' [a-zA-Z_] [a-zA-Z0-9_]*; +INT: [0-9]+; +STRING: '"' ('\\"' | ~[\r\n"])* '"'; +IDENTIFIER: [a-z] [a-z0-9_]*; + +LPAREN: '('; +RPAREN: ')'; +LBRACKET: '['; +RBRACKET: ']'; +COMMA: ','; +EQ: '='; +PLUS: '+'; +MINUS: '-'; +PLUS_EQ: '+='; +MINUS_EQ: '-='; +LT: '<'; +GT: '>'; +UNDERSCORE: '_'; diff --git a/Justfile b/Justfile index ae23cdee..b586574f 100644 --- a/Justfile +++ b/Justfile @@ -15,6 +15,7 @@ tidy: generate: @antlr4 -Dlanguage=Go Lexer.g4 Numscript.g4 -o internal/parser/antlrParser -package antlrParser @mv internal/parser/antlrParser/_lexer.go internal/parser/antlrParser/lexer.go + @antlr4 -Dlanguage=Go IR.g4 -o internal/ir/internal/syntax/antlrParser -package antlrParser tests: @go test -race -covermode=atomic \ diff --git a/compiler-architecture.md b/compiler-architecture.md new file mode 100644 index 00000000..e78a495c --- /dev/null +++ b/compiler-architecture.md @@ -0,0 +1,527 @@ +# Numscript vm architecture + +## Design goals + +Because of new requirements in the ledger v3, we are redesigning how numscript is architected. +Previous architecture (still implemented in this repo) consisted in a simple data flow: +We parsed the ast, and then walked the Ast to interpret it and emit postings: + +```mermaid +stateDiagram + direction TB + Still --> s8:parse + s8 --> s7:run (with vars) + Still:source code + s8:Ast + s7:postings +``` + +while this is the simplest architecture we could implement, and its performance was still good enough for our use cases, a few things changed with the ledger v3 design: + +1. Higher ledger TPS: numscript is more likely to become a bottleneck. So it's now justified to pay with more complexity for better perfs. Tree walker interpreter is usually suboptimal, has a lot of pointer chasing, and makes it hard to optimise things +2. It needs a way to send the programs around the nodes in a compact and efficient way. This could be solved in the previous architecture by using the syntax itself as a serializations format, or with some rpc encoding, but would still require complex operations on the nodes +3. We now have a specific section which is sequential and needs maximum perf. So we now prefer an architecture that allows to have pre-computed optimisations in the parallel path, so that the sequential one is highly optimised + +that lead to researching a new implementation that would fit those design goals better + +## The overall architecture + +We now compile the parsed Ast (using `compiler.Compile`) and get a `vm.Program` struct and a `compiler.VarsEncoder` struct (or a compilation error). The compiler can optionally run an optimisation pass. + +The `vm.Program` is used to create a `vm.Vm` instance (via `vm.NewVm`). Creating a `vm.Vm` instance will allocate the relevant registers and data, but it's designed so that we can reuse the same `vm.Vm` instance across execution of the same program. + +The `VarsEncoder` struct knows how to encode a json payload (a `map[string]string`) into `vm.Vars`. + +Finally, we can obtain our postings and meta output by running the `vm.Exec` function, by passing the `vm.Vm` instance, a `Store` implementation (used by the vm to fetch balances and meta), and the `vm.Vars`. + +An important property: Both `vm.Program` and `vm.Vars` can be encoded and decoded as bytes. This way, we can orchestrate the previously mentioned flow: + +- The leader node can parse and pre-compile numscript into a `vm.Program` and keep the `compiler.VarsEncoder`. The `vm.Program` is serialised into bytes and sent to nodes, which'll deserialise it into `vm.Program` again, and used to create the instance of the `vm.Vm`. +- On each tx, the leader takes the json payload and turns it into `vm.Vars` with the `vm.Encoder`. `vm.Vars` are serialised, sent and deserialised back. Now node can finally run the highly optimised warm `vm.Vm` instance. + +Diagram is roughly like this: + +```mermaid +stateDiagram + direction TB + s2 --> s3:encode + s3 --> s2:decode + s2 --> s4 + s4 --> s7:exec + Still --> s8:parse + s8 --> s2:compilation + s8 --> s6:compilation + s6 --> s9:encode vars payload + s9 --> s10:encode + s10 --> s9:decode + s9 --> s7 + s2:compiled program + s3:program bytecode repr + s4:vm instance + s7:postings + Still:source code + s8:Ast + s6:vars encoder + s9:vm.Vars + s10:vars bytecode repr +``` + +In the next section we'll see how each of those building blocks works exactly + +## Vm architecture + +The vm's instance is composed by: + +- the `vm.Program` (the compilation artifact) +- registers +- the runtime's state, which keeps track of allocated funds and the accounts' balances. + +The bytecode's instructions have a fixed 32bit size: + +```go +package vm + +type Instruction struct { + Opcode byte + A byte + B byte + C byte +} +``` + +Because of the fixed-size, the vm can keep the hydrated buffer of `[]Instruction` stream instead of having to parse things on the fly at runtime (or without having to model it via heap-allocated structs) while still being compact enough to benefit good CPU locality. + +Instructions come in 2 formats: either `ABC` (3 arguments of 1 byte each) or `ABB` (2 arguments, with 1 having 1 byte size and the other a little endian repr of 2bytes). +If an instruction doesn't fit the 4 bytes limit, we simply extend it with the `Instruction` after that. + +Instructions are fetched and evaluated one at the time until they are finished (no HALT instructions to stop, so that bytecode always terminates by design). + +Instructions can move data by manipulating registers. Registers banks are separated by type (so that we don't have to have a single heap-allocated value, nor unsafe pointers or manually handled unsafe memory). With "type" here we mean the internal representation of data, which isn't the same as numscript types (there isn't necessarily a 1-1 relationship). For example, both strings, assets and accounts are represented via the golang `string` type. The `bool` bank is the other direction: it has no numscript counterpart at all, and exists so that a branch condition can't be a monetary quantity. + +> Note: we'll probably change accounts' representation when adding scopes + +A simple example of an instruction is: + +``` +INT_ADD 0x00 0x01 0x02 +``` + +which behaves like this: + +``` +int_registers[0x00] = int_registers[0x01] + int_registers[0x02] +``` + +Note that this model plays very well with golang's `big.Int` mutable API. + +The instruction set has + +- a few pure, binary or unary arithmetic/logic operations (int min, string add, int add, portion sub, etc) +- a few domain instructions which call the `runtime.RunState`'s API (such as `PULL_ACCOUNT`, `SEND_TO_ACCOUNT`, `SAVE`). Those domain primitives can allocate funds, pull them to allocate postings, etc. This runtime logic is shared with the interpreter implementation. +- conditional jumps (`JMP_IF_ZERO`), which can only jump forward (so that the vm always halts by design) +- a `MK_ALLOTMENT` instruction which computes the allotment-related calculations +- constant pool loading instructions: `LOAD_STR(dest:u8, idx:u16)`, which performs `str_regs[dest] = program.str_pool[idx]`, and `LOAD_INT`. +- `LOAD_VAR_STRING(dest:u8, idx:u16)`, which performs `str_regs[dest] = vars.str_pool[idx]`, and `LOAD_VAR_INT` instructions, to load vars encoded in the `vm.Vars` struct + +The VM implementation itself is trivial, and most of the complexity is moved to the compiler + +### Bytecode encoding + +The program bytecode encoding is designed so that the hydration can be fast, and so that it stays stable across versions. + +After a magic word (so that we reject right away random bytes that didn't come from the compiler), we have a small header with a format version and the number of sections. The version lets a decoder reject a payload encoded by a newer, incompatible version instead of silently misreading it. + +``` +| "NUMB" 4 B | magic ++-------------------------------+ +| version : u16 2 B | header +| count : u16 2 B | ++-------------------------------+ +| section 0 | sections +| section 1 | +| ... | +| section `count`-1 | ++-------------------------------+ + +section ++-----------+-----------+---------------+ +| tag : u16 | len : u32 | content ... | len = content byte length ++-----------+-----------+---------------+ + 2 B 4 B +``` + +Each section id identified by its `tag` identifier. `len` is the number of bytes it takes. + +Current sections are + +- Instructions, hydrated into a `[]vm.Instruction` slice +- Str pool, hydrated into a `[]string` slice +- Int pool, hydrated into a `[]big.Int` slice + +Missing sections are valid and considered as empty. Unkown sections are allowed and skipped, unless the 15-th bit(`0x8000`) is set; in that case the program is rejected. + +> Note: the compiler is free to arrange the sections in any order (e.g. it may pad or align them in future optimizations) + +> Note: this design would make it possible to have very fast hydration by re-intepreting the instruction slice via unsafe casting, or by using mmap. In our case this is more dangerous than useful, but it's a nice property to have + +### Constant pools + +Both the str and int pool start with the count of elems and then have contiguos sequence of strings/ints. + +``` +str pool section int pool section ++-------------+--------------+ +-------------+-------------+ +| count : u32 | records ... | | count : u32 | records ... | ++-------------+--------------+ +-------------+-------------+ + +str record int record ++------------+-------------+ +------+-------------+----------------+ +| len : u32 | raw bytes | | sign | magSz : u32 | magnitude ... | ++------------+-------------+ +------+-------------+----------------+ + 1 B big-endian +``` + +Int follows the same encoding as its `.Bytes()` and `.SetBytes()` methods. + +### Vars encoding + +`vm.Vars` uses the exact same encoding, just without the instructions section: the magic word is `"NVAR"`, and it only carries the str pool and int pool sections. + +``` +| "NVAR" 4 B | magic ++-------------------------------+ +| version : u16 2 B | header +| count : u16 2 B | ++-------------------------------+ +| str pool section | +| int pool section | ++-------------------------------+ +``` + +The version/section framing and the pool encoding are the same code as the program's, just parametrized by the magic word and the set of sections. + +An important property is that the `vm.Vars` don't have a 1-1 correspondence with the vars. The `vm.Vars` only encodes ints and strings. Composite objects, such as monetaries, are split into 2 different vars. This keeps data encoding minimal, and makes optimizations surface simpler to implement (see optimisations section below). + +The same principle now applies inside the VM, not just at the vars boundary: a monetary **is** a (str asset, int amount) register pair everywhere. There is no monetary register bank and no instruction that builds or projects one. + +The compiler is free to choose any encoding it wants for the vars (e.g. the first value in the str pool doesn't have to be the first string variable). Behaviour can change across versions. + +### Soundness verification + +> [!NOTE] +> This isn't yet implemented in the `feat/exp/vm` branch. There is a branch with a POC of those checks. + +Even if there are bugs in the compiler, we can analyse the bytecode to prove statically that the bytecode can't make the vm crash, that the computation always halts (the instruction set is designed so that this is a decidable problem). We can also prova statically most of the interesting properties that ensure that the bytecode isn't resulting in undefined behaviour. +Some of the examples are: + +- No undefined opcodes. Ensures no panic +- Extended instructions aren't truncated. Ensures no panic +- Const idx doesn't overflow the const pool array. Ensures no panic +- Var idx doesn't overflow the vars pool array. Ensures no panic +- We don't overflow the max register declared by the compiler output. Ensures no panic +- Only jump forward. Ensures termination +- No read before write (undefined behaviour). This ensures we can re-use vm instances. Note this has to be checked on every path (including possible jumps) + +Vm is simple enough that we can easily audit every line of code that could panic (e.g. array access), and perform static checks on bytecode. + +The static check is optional: the compiler should emit valid bytecode anyway. +Still, we can use this as a sanity-check right after program is compiled, or after the raft node receives the bytes payload, to make sure nothing went wrong in the meanwhile. + +## Compiler + +Instead of emitting a `vm.Instruction{}` stream directly, the compiler emits a `[]ir.Instr` slice. That's an intermediate representation of the instruction which isn't strictly necessary, but allows us to dump, manipulate or analyse instruction without having to run a fully-fledged disassembler every time. After the compilation, the `[]ir.Instr` are assembled into `[]vm.Instruction`. +The instruction set is mostly similar, but there are a few differences. +The most crucial one is that instead of many separate pools of 256 registers, there's a single infinite stream of registers. +We'll materialise those "logical" registers into actual physical registers during assembly, and perfom register allocation policies so that we'll be able to fit scripts within the 256 registers constraint. +We are able to fully typecheck the `[]ir.Instr` program, so that we know that we aren't passing logical registers that were created with a different type. + +Other differences in the instruction set include: + +- instead of `LOAD_INT` or `LOAD_STRING` referencing constant pool index, we have a `loadInt{ dest reg; value big.Int }` and `loadString{ dest reg; value string}` which handle populating and deduping constant pool when assembling, or using specialised instructions like `LOAD_INT_IMMEDIATE` instructions which contain the number in the payload itself. +- we have a `labelMarker struct{ label string }` pseudo-instruction. This way the jump can reference an instruction that hasn't been emitted yet without complex hacks at compile time + +This split allows us to implement peephole optimisations (bytecode rewriting) - see the "optimisation" section. + +We'll use the `irInstr` notation in the following sections: + +``` +// instructions can have many args, which may have labels, +// and may write the result into another register +$my_reg = some_instr($arg_reg, label: $another_arg) + +// consts use literals directly: +$some_int = 42 +$some_str = "USD/2" + +// special syntax for int math: +$tot = $x + $y +// auto-increment syntax +$tot += $x +``` + +This notation is a real format: it has a grammar ([IR.g4](IR.g4)), a parser (`ir.Parse`) and a dumper (`ir.Dump`), and instructions round-trip through it. It's fully specified in [ir-textual-format.md](ir-textual-format.md) — including the instruction reference, the argument conventions and the known round-trip caveats. + +In the sections below, a meta-notation is used for parametrized exprs/sources/dests + +#### Bounded send statement + +```num +send ( + source = + destination = +) +``` + +``` +$asset, $amount = // two regs, no instruction of its own +set_current_asset($asset) // needed for pull_account and send_to_account + +// a source always compiles by putting the pulled amt into a reg +$pulled = + +// we check if we managed to pull enough funds, or fail due to missing funds +check_enough_funds($pulled, $amount) + + +``` + +#### Plain account source/dest (bounded) + +Let's compile the `@src` source account, bounded by the value in the `$amount` reg. +It'll write pulled amount into the `$pulled` reg: + +``` +$src = "src" +$overdraft = 0 +$eq = str_eq($src, $world) +jmp_if_false($eq, #not_world) + $pulled = pull_account(account: $src, cap: $amount) + jmp(#pull_end) +#not_world + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) +#pull_end +``` + +The branch is `@world`. A pull with no `overdraft` operand is *unbounded*: it makes +the whole cap available without ever reading a balance, which is exactly what +`@world` means. The VM knows nothing about the name — the account is a register, so +it could equally come from a var, an interpolation or a `meta()` read, and the +comparison has to happen at run time. `$world` is a single `load_str "world"` in +the program prologue, so it dominates every such branch no matter what jumps the +branch sits between. + +This is emitted for *every* source account, literal `@world` or not. One code path +is easier to trust than a compile-time-folded one, and collapsing it back down is a +peephole's job: const-fold `str_eq` over two known `load_str`s, then drop the dead +arm. Until those land, the diamond is the cost of the VM not knowing about `@world`. + +When the source is `allowing unbounded overdraft` there is no `overdraft` operand +to drop, so both arms would be identical and the branch is skipped entirely. + +the plain `@dest` destination account will look like: + +``` +$dest = "dest" +send_to_account(account: $dest) +``` + +Here's a full example of a send statement: + +``` +send [USD/2 10] ( + source = @src + destination = @dest +) +``` + +output: + +``` +$world = "world" +$asset = "USD/2" +$amount = 10 +set_current_asset($asset) +$src = "src" +$overdraft = 0 +$eq = str_eq($src, $world) +jmp_if_false($eq, #not_world) + $pulled = pull_account(account: $src, cap: $amount) + jmp(#pull_end) +#not_world + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) +#pull_end +check_enough_funds($pulled, $amount) +$dest = "dest" +send_to_account(account: $dest) +``` + +#### Inorder sources (bounded) + +Let's compile the inorder source `, .., `, by storing the pulled amt in the `$pulled` register, bounded by the `$amount` cap. + +``` +$pulled = 0 +$remaining = int_copy($amount) + +// first source +$pulled_s1 = +$pulled += $pulled_s1 +$remaining -= $pulled_s1 +$exhausted = is_zero($remaining) +jmp_if_true($exhausted, #inorder_end) + +// second source +$pulled_s2 = +$pulled += $pulled_s2 +$remaining -= $pulled_s2 +$exhausted = is_zero($remaining) +jmp_if_true($exhausted, #inorder_end) + +.. + +$pulled_sn = +$pulled += $pulled_sn +// last one doesn't need jump + +#inorder_end +``` + +#### Max source (bounded) + +Let's compile `max from `, bounded by the `$amount` cap. + +``` +$max_asset, $max_amount = +assert_same_asset($max_asset, $asset) // $asset is the current asset (set via set_current_asset) + +// $cap = min($max_amount, $amount): there is no min opcode, so it is a comparison +// and a copy. Copying the left operand first saves the else arm's `jmp`. +$cap = int_copy($max_amount) +$lt = lt_int($max_amount, $amount) +jmp_if_true($lt, #min_end) +$cap = int_copy($amount) +#min_end + +$pulled = +``` + +The speculative copy is only sound because `$cap` is freshly allocated: an aliased dest would clobber `$amount` before the else arm reads it. If there's no outer cap (an unbounded context), the whole min is skipped and the inner source is capped by `$max_amount` directly. + +#### Allotment source (bounded) + +Let's compile the allotment source ` from , .., from `, bounded by the `$amount` cap. +Note that an allotment source is always bounded. + +``` +// portions must cover exactly 1 (no `remaining` clause here) +$leftover = 1 - - .. - +assert_leftover_exact($leftover) // plain "assert_leftover" if there's a remaining clause + +// split $amount across the portions. There is no allotment instruction: each +// share is a floored product, and the leftover from flooring is handed to the +// earliest shares one unit at a time. n is statically known, so the fixup is +// unrolled -- and only n-1 blocks are needed, since the shortfall is < n. +$amount_p = int_to_portion($amount) +$share_1 = portion_to_int(mul_portion(, $amount_p)) +.. +$share_n = portion_to_int(mul_portion(, $amount_p)) +// then, for i in 1..n-1: if $total < $amount { $share_i += 1; $total += 1 } + +$pulled_s1 = +check_enough_funds($pulled_s1, $share_1) + +.. + +$pulled_sn = +check_enough_funds($pulled_sn, $share_n) +``` + +### Optimisations + +> [!NOTE] +> Peephole optimisations aren't yet implemented in the `feat/exp/vm` branch. There is a POC in another branch, to measure how much perf could be impacted, but it's too soon to consider + +You may have noticed that the previous compilation examples emit _a lot_ of garbage. +That's done by design: the compiler must be simple and declarative. We don't want dozens of special cases in the compilation logic, which must express a general, albeit redundant, template which focuses on correctness. + +One whole class of that garbage is gone for good, though, and not via a peephole: monetaries used to be boxed with `mk_monetary` and immediately unboxed with `get_asset`/`get_amount`, so 3 of the 12 instructions for a simple `send` were pure round-tripping. Representing a monetary as a register pair removes them at codegen time, which is why there is no `monetaryFold` peephole to write. + +That change also *enables* a peephole that was previously out of reach. `assert_same_asset` used to compare two `get_asset(mk_monetary(..))` results, whose provenance is invisible without folding first; now both operands are plain `load_str`s, so an asset comparison between two literals is statically decidable and the assert can be dropped outright. + +However, the `irInstr` layer allows us to easily rewrite the instructions so that we remove garbage instructions, precomputing more aggressively, rewrite them into more efficient code (this is called [peephole optimisation](https://en.wikipedia.org/wiki/Peephole_optimization)) + +Each peephole is independent and is expressed as a `func(instr []irInstr) []irInstr`, which returns the new instructions set, or nil if it didn't change. +Each peephole is independently testable and reviewable. + +We apply each peephole optimisation sequentially, and repeat until we reach a fixed point for each peephole (the program `p` such that `f(p) == p`, where `f` is the peephole function). + +Note that proving that a peephole function _does_ have a fixed point is usually simple, whereas proving that the function composition of all the peepholes isn't. Pratically speaking, we can avoid non-terminating optimisation passes by imposing a max amount of optimisation passes. A clever order of peepholes should make convergence quite fast anyway. + +> TODO list some peepholes + +The `@world` diamond (see [Plain account source/dest](#plain-account-sourcedest-bounded)) is +the clearest case to date, and it needs two passes that compose: + +1. **Const-fold `str_eq`** — when both operands trace back to `load_str` constants, the + comparison is statically decidable: replace it with `true` or `false`. +2. **Dead-branch elimination** — a conditional jump on a register holding a known bool + is either a no-op or an unconditional `jmp`; then everything between a `jmp` and the + next reachable label is unreachable. `is_zero` over a `load_int` folds the same way, + which is what makes the quantity branches reachable for this pass too. + +Together they collapse a literal `@world` source back to a single unbounded +`pull_account`, and any other literal account back to a single bounded one — i.e. to +exactly the code the compiler emitted when the VM still special-cased the name. The +prologue's `load_str "world"` also becomes dead once no branch refers to it. + +### Registers allocator + +> [!NOTE] +> Currently implemented allocator is a bump allocator: allocate a fresh register for each distinct logical register. A linear-scan allocator is prototyped in another branch. + +After optimisation pass is (optionally) run, we can materialise logical registers into physical registers of each type bank during assembly phase. + +A good register allocation algorithm can reduce the number of needed registers. +For example, consider the `($x + $y) * $z` expression: + +``` +$x = load_var(idx: 0) +$y = load_var(idx: 1) +$z = load_var(idx: 2) +$w = $x + $y +$res = $w * $z +``` + +A naive allocation (bump allocation: materialise each distinct logical register into a fresh physical register) would assemble this into: + +``` +// need 5 registers in total +LOAD_VAR_INT(dest: 0, idx: 0) +LOAD_VAR_INT(dest: 1, idx: 1) +LOAD_VAR_INT(dest: 2, idx: 2) +ADD_INT(dest: 3, left: 0, right: 1) +MUL_INT(dest: 4, left: 3, right: 2) +``` + +Whereas an optimal allocation would produce something like this: + +``` +// need 2 registers in total +LOAD_VAR_INT(dest: 0, idx: 0) +LOAD_VAR_INT(dest: 1, idx: 1) +ADD_INT(dest: 0, left: 0, right: 1) +LOAD_VAR_INT(dest: 1, idx: 2) +MUL_INT(dest: 0, left: 0, right: 1) +``` + +What a better register allocation buys us is: + +1. better CPU locality, thus higher runtime speed (probably irrelevant gain in our case) +2. less memory used: the initial vm load will have to load less registers (although max number of registers per bank is 256 anyway) +3. avoid having to forbid scripts that overflow the 256 registers limit, or having to implement register spilling behaviour (the most important improvement) + +Registers allocation is a widely studied topic, so we don't really have to discover anything new. +There are more aggressive and expensive algorithms that are able to produce the most optimal registers allocation (e.g. by having to compute graph coloring, a provably expensive problem), which we don't need in our case: we still need decent perf at compile time as well, and a simpler allocation will most likely be "good enough". +Specifically, a [linear scan allocation](https://web.cs.ucla.edu/~palsberg/course/cs132/linearscan.pdf) will get us very close to the optimal allocation with `O(n)` cost. + +> Note: Claude argues that, for our instruction set, linear scan would produce _exactly_ the same result as the optimal allocation algorithms. I haven't yet put effort in understanding whether that's the case and why that is diff --git a/instruction-encoding.md b/instruction-encoding.md new file mode 100644 index 00000000..04fab9d4 --- /dev/null +++ b/instruction-encoding.md @@ -0,0 +1,468 @@ +# VM Bytecode Specification (proposal) + +Instructions are **4 bytes** wide: `[Opcode: 8] [A: 8] [B: 8] [C: 8]`. + +- Registers are split into **per-type banks** (`int_regs`, `str_regs`, `por_regs`, `bool_regs`); an operand indexes the bank implied by the opcode. There is no monetary bank: a monetary is a (`str_regs` asset, `int_regs` amount) pair, so the instructions that deal in monetaries take or return the two halves separately. +- `0xFF` in a register slot means **nil** (absent optional operand). +- **`Bx`** = a `u16` formed by slots `B`,`C` (little-endian); used for pool indices and jump targets. **`sBx`** is its signed form. +- Most instructions are one word. A few extend into **continuation words** (shown as `↳ cont.`); an instruction's length is fixed by its opcode. +- There is **no `HALT`**: programs terminate by design (jumps are forward-only). +- Opcodes are grouped by category with gaps, so new instructions slot into a category without renumbering. Unused values are reserved (users can't emit them, so we stay free to define them later). + +> Opcode numbers are a proposal and don't yet match the `iota` values in `instruction.go`. + +--- + +## 1. State & Assertions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
00x00SET_CURRENT_ASSETasset--Sets the current asset (used by PULL_ACCOUNT / SEND_TO_ACCOUNT) from str_regs[A]
10x01ASSERT_SAME_ASSETxy-Traps unless str_regs[A] and str_regs[B] are the same asset
20x02ASSERT_VALID_ACCOUNTacc--Traps if the account name in str_regs[A] is malformed
30x03ASSERT_NON_NEGATIVE_BALANCEamtacc-Traps if int_regs[A] is negative; B = account (for the error)
40x04ASSERT_LEFTOVERporexact-Traps if por_regs[A] is negative; when B == 1 (no remaining) also traps if non-zero
50x05CHECK_ENOUGH_FUNDSpulledtarget-Traps if int_regs[A] < int_regs[B] (missing funds)
60x06ASSERT_VALID_COLORcolor--Traps if the color in str_regs[A] is malformed (only uppercase letters; the empty string is valid)
0x07..0x0F reserved
+ +## 2. Constants & Variables + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
160x10LOAD_INTdestBx (const idx)int_regs[A] = int_pool[Bx]
170x11LOAD_STRdestBx (const idx)str_regs[A] = str_pool[Bx]
180x12LOAD_VAR_INTdestBx (var idx)int_regs[A] = vars.int_pool[Bx]
190x13LOAD_VAR_STRdestBx (var idx)str_regs[A] = vars.str_pool[Bx]
200x14LOAD_INT_IMMEDIATEdestsBx (i16 value)int_regs[A] = (big.Int)sBx — small literals inline, no pool entry. Reserved; not implemented
210x15CONST_TRUEdest--bool_regs[A] = true — the value is in the opcode, so there is nothing to decode and no pool entry
220x16CONST_FALSEdest--bool_regs[A] = false
0x17..0x1F reserved
+ +## 3. Metadata + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
320x20SET_TX_METAkeyval-Sets transaction metadata str_regs[A] = str_regs[B]
330x21SET_ACCOUNT_METAacckeyvalSets account metadata: account A, key B, value C
340x22META_STRdestacckeystr_regs[A] = meta(account B, key C)
350x23META_INTdestacckeyas META_STR, typed int
360x24META_PORTIONdestacckeyas META_STR, typed portion
370x25META_MONETARYdest assetacckeyParses the value as a monetary. One store read yields both halves, so this is the only two-destination read: str_regs[A] = asset
↳ cont.dest amt--int_regs[A] = amount
0x26..0x2F reserved
+ +## 4. Arithmetic & Constructors (binary) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
480x30ADD_INTdestleftrightint_regs[A] = int_regs[B] + int_regs[C]
490x31SUB_INTdestleftrightint_regs[A] = int_regs[B] - int_regs[C]
0x32 reserved (was MIN_INT: a min is a comparison and a copy, so it is LT_INT plus a branch)
510x33SUB_PORTIONdestleftrightpor_regs[A] = por_regs[B] - por_regs[C]. Its counterpart ADD_PORTION is at 0x38, not adjacent, because 0x32 is burned and 0x34..0x37 were taken
520x34MK_PORTIONdestnumdenpor_regs[A] = int_regs[B] / int_regs[C]
0x35 reserved (was MK_MONETARY: a monetary is a register pair, nothing to construct)
540x36ADD_STRINGdestleftrightstr_regs[A] = str_regs[B] + str_regs[C]
0x37 reserved (was STR_EQ: moved to the comparison group, §7, now 0x62)
560x38ADD_PORTIONdestleftrightpor_regs[A] = por_regs[B] + por_regs[C]. A rational sum, so unequal denominators combine correctly and the result is normalised; it may exceed 1
0x39..0x3F reserved
+ +## 5. Unary & Conversions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
0x40..0x41 reserved (were GET_AMOUNT / GET_ASSET: projecting a monetary is naming one of its two registers, so it costs no instruction)
660x42INT_COPYdestsrc-int_regs[A] = int_regs[B] (fresh copy). One copy per bank, none crossing banks; the family is split across 0x42..0x43 and 0x4A..0x4B because 0x44..0x49 were already spoken for. No monetary copy: a monetary is a (str, int) pair, so copy the halves
670x43PORTION_COPYdestsrc-por_regs[A] = por_regs[B] (fresh copy)
680x44NEG_INTdestsrc-int_regs[A] = -int_regs[B]
690x45INT_TO_STRINGdestsrc-str_regs[A] = str(int_regs[B])
700x46PORTION_TO_STRINGdestsrc-str_regs[A] = str(por_regs[B])
710x47MONETARY_TO_STRINGdestassetamtstr_regs[A] = str_regs[B] + " " + str(int_regs[C]) — takes both halves, so it is ternary despite living in this section
0x48 reserved (was IS_ZERO: moved to the comparison group, §7, now 0x63)
0x49 reserved (was NOT: moved to the bool-ops group, §8, now 0x70)
740x4ASTR_COPYdestsrc-str_regs[A] = str_regs[B]
750x4BBOOL_COPYdestsrc-bool_regs[A] = bool_regs[B]
0x4C..0x4F reserved
+ +## 6. Funds & Postings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
800x50PULL_ACCOUNTdestacccapPulls funds from account B capped by int_regs[C] (0xFF = uncapped); pulled amount → int_regs[A]. 2 words:
↳ cont.overdraftcolor-Overdraft cap reg and color reg (0xFF = none)
810x51SEND_TO_ACCOUNTacccapcolorEmits a posting to account A (0xFF = world), each operand optional (0xFF = none)
820x52SAVEaccassetamountReduce balance of account A for asset B by int_regs[C] (C = 0xFF ⇒ save all), floored at 0
830x53MK_ALLOTMENTdest0in0sizeSplits the current amount across size portions in por_regs[in0..], writing shares to int_regs[dest0..]
840x54BALANCEdest amtaccassetint_regs[A] = balance(account B, asset C) from the run-state. Only the amount: the resulting monetary's asset is operand C, which the caller already holds
850x55SNAPSHOTdest--int_regs[A] = current source-queue mark (len(sources)), for oneof backtracking
860x56RESTOREsnap--Rolls the source queue back to the mark in int_regs[A] (repays debited balances, then truncates)
0x57..0x5F reserved (e.g. PULL_ACCOUNT specializations). This block used to run to 0x8F; §7 and §8 took 0x60..0x7F out of it, leaving nine slots for the four specializations sketched in instruction.go
+ +## 7. Comparisons + +Every bool producer lives here. `A` = dest (a `bool_regs` index) for all of them; the operand banks are what the opcode implies. `IS_ZERO` is unary and the rest are binary — they are one group because they are one *category*, not one arity. + +Only `<` and `==` exist, per type. The other four surface operators are **normalised by the front end**: + +| surface | lowering | +|---|---| +| `a < b` | `Lt(a, b)` | +| `a > b` | `Lt(b, a)` — operands swapped | +| `a <= b` | `Not(Lt(b, a))` | +| `a >= b` | `Not(Lt(a, b))` | +| `a == b` | `Eq(a, b)` | +| `a != b` | `Not(Eq(a, b))` | + +12 surface operators, 5 opcodes. Every extra predicate is another case in the SMT encoder and in any formal model of the VM, so the cost would be paid three times over. LLVM does the same, canonicalising `sgt` to `slt` with swapped operands in InstCombine so downstream passes only ever see one form. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
960x60LT_INTdestleftrightbool_regs[A] = int_regs[B] < int_regs[C]. Strict
970x61EQ_INTdestleftrightbool_regs[A] = int_regs[B] == int_regs[C]
980x62STR_EQdestleftrightbool_regs[A] = str_regs[B] == str_regs[C]. The only string comparison that yields a value rather than trapping (cf. ASSERT_SAME_ASSET). Was 0x37
990x63IS_ZEROdestsrc-bool_regs[A] = int_regs[B].Sign() == 0 — the projection from a quantity to a condition, since the jumps take a bool. Tests the sign, so a negative amount is not zero. Kept alongside EQ_INT because it needs no materialised zero and it is on every quantity branch. Was 0x48
1000x64LT_PORTIONdestleftrightbool_regs[A] = por_regs[B] < por_regs[C]. Strict, and by value — see EQ_PORTION
1010x65EQ_PORTIONdestleftrightbool_regs[A] = por_regs[B] == por_regs[C]. Value equality: 1/2 == 2/4 is true. big.Rat normalises on construction, so the rationals are compared — comparing numerator/denominator pairs separately would give the wrong answer
0x66..0x6F reserved for < and == on types that don't exist yet. Str gets equality only, never ordering. Bool equality, and structural comparison of tuples/arrays, are front-end expansions rather than opcodes. No named-but-unimplemented constants live here on purpose: a live opcode with no emitter invites a second lowering path that no test exercises
+ +## 8. Bool ops + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
1120x70NOTdestsrc-bool_regs[A] = !bool_regs[B] — the only operation whose operand and result are both bools, and what the four derived operators above are built from. Was 0x49
0x71..0x7F reserved for and/or, if they ever pay for themselves — both are expressible as branches, so neither is needed for completeness
0x80..0x8F reserved
+ +## 9. Control Flow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OpcodeHexNameABCDescription
1440x90JMP_IF_FALSEcondBx (forward delta)If bool_regs[A] is false, skip Bx instructions: pc += Bx, where pc already points at the next instruction. Being an unsigned delta, the jump is forward-only (guarantees termination). A quantity is not a condition — project it with IS_ZERO first
1450x91JMPBx (forward delta)Unconditional: pc += Bx. Forward-only, as above
1460x92JMP_IF_TRUEcondBx (forward delta)The dual of JMP_IF_FALSE, so either edge of a condition is one instruction and no negation opcode is needed
0x93..0xFF reserved
diff --git a/internal/builtins/builtins.go b/internal/builtins/builtins.go new file mode 100644 index 00000000..c12ef614 --- /dev/null +++ b/internal/builtins/builtins.go @@ -0,0 +1,11 @@ +package builtins + +const ( + SetTxMeta = "set_tx_meta" + SetAccountMeta = "set_account_meta" + Meta = "meta" + Balance = "balance" + Overdraft = "overdraft" + GetAsset = "get_asset" + GetAmount = "get_amount" +) diff --git a/internal/cmd/assemble.go b/internal/cmd/assemble.go new file mode 100644 index 00000000..2832f1e6 --- /dev/null +++ b/internal/cmd/assemble.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/formancehq/numscript/internal/ir" + + "github.com/spf13/cobra" +) + +type AssembleArgs struct { + OutputPath string +} + +// stdioPath is the conventional stand-in for stdin/stdout, accepted both as the +// input path and as --output. +const stdioPath = "-" + +// defaultBytecodePath derives the output path from the IR path: "x.ir" becomes +// "x.numb", anything else just gains the suffix. Reading from stdin has no path +// to derive from, so it writes to stdout. +func defaultBytecodePath(irPath string) string { + if irPath == stdioPath { + return stdioPath + } + return strings.TrimSuffix(irPath, ".ir") + ".numb" +} + +func readIRSource(irPath string) ([]byte, error) { + if irPath == stdioPath { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(irPath) +} + +func assemble(irPath string, opts AssembleArgs) error { + content, err := readIRSource(irPath) + if err != nil { + return err + } + src := string(content) + + instrs, irErrs := ir.Parse(src) + if len(irErrs) != 0 { + for _, irErr := range irErrs { + fmt.Fprintln(os.Stderr, irErr.Error()) + fmt.Fprint(os.Stderr, irErr.Range.ShowOnSource(src)) + } + return fmt.Errorf("assembling failed") + } + + if err := ir.Typecheck(instrs); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return fmt.Errorf("assembling failed") + } + + program, err := ir.Assemble(instrs) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return fmt.Errorf("assembling failed") + } + + bytecode := program.Encode() + + outputPath := opts.OutputPath + if outputPath == "" { + outputPath = defaultBytecodePath(irPath) + } + if outputPath == stdioPath { + _, err := os.Stdout.Write(bytecode) + return err + } + + return os.WriteFile(outputPath, bytecode, 0o644) +} + +func getAssembleCmd() *cobra.Command { + opts := AssembleArgs{} + + cmd := cobra.Command{ + Use: "assemble ", + Short: "Assemble a textual IR file into bytecode", + Long: `Assemble a textual IR file into the binary bytecode the vm executes. + +The output goes to the input path with a ".numb" extension, for example: +assemble folder/my-script.ir +will write 'folder/my-script.numb'. + +Use --output to write elsewhere, or --output - to write the bytecode to stdout. + +Pass - as the path to read the IR from stdin, in which case the bytecode goes to +stdout unless --output says otherwise: +cat folder/my-script.ir | numscript assemble - > folder/my-script.numb + +The IR format tracks an unstable instruction set and is not a public interface. +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + err := assemble(args[0], opts) + if err != nil { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return err + } + + return nil + }, + } + + cmd.Flags().StringVarP(&opts.OutputPath, "output", "o", "", "Path where to write the bytecode ('-' for stdout)") + + return &cmd +} diff --git a/internal/cmd/bytecode_run.go b/internal/cmd/bytecode_run.go new file mode 100644 index 00000000..4db68d93 --- /dev/null +++ b/internal/cmd/bytecode_run.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "sort" + "strings" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + + "github.com/spf13/cobra" +) + +// VarsPoolFile is the raw form of vm.Vars: the compiler's VarsEncoder maps +// declared variable names onto pool slots, but it lives in the source, not in +// the bytecode, so a bytecode-only run has to name the slots by index. Ints are +// strings so that values beyond float64/int64 survive the JSON round-trip. +type VarsPoolFile struct { + Strings []string `json:"strings"` + Ints []string `json:"ints"` +} + +// BytecodeInputsFile is the `run` inputs file plus the vars pool. It is a +// separate type so that the vm's index-addressed vars stay out of the +// interpreter's inputs shape; the shared fields keep the same json names, so one +// .inputs.json works for both commands. +type BytecodeInputsFile struct { + Meta interpreter.AccountsMetadata `json:"metadata"` + Balances interpreter.Balances `json:"balances"` + VarsPool *VarsPoolFile `json:"varsPool"` +} + +type BytecodeRunArgs struct { + InputsPath string + VarsPath string + OutFormatOpt string +} + +// vmStore is a vm.Store over the rows of an inputs file. +type vmStore struct { + balances map[runtime.PairKey]*big.Int + meta map[string]map[string]string +} + +func (s vmStore) GetBalance(_ context.Context, account, asset, color string) (*big.Int, error) { + // the caller owns what it gets: the run state mutates balances in place + if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + return new(big.Int).Set(v), nil + } + return new(big.Int), nil +} + +func (s vmStore) GetMetadata(_ context.Context, account, key string) (string, bool, error) { + v, ok := s.meta[account][key] + return v, ok, nil +} + +func newVmStore(inputsPath string, inputs BytecodeInputsFile) (vmStore, error) { + store := vmStore{ + balances: make(map[runtime.PairKey]*big.Int, len(inputs.Balances)), + meta: make(map[string]map[string]string), + } + + for _, row := range inputs.Balances { + // the vm never sets a scope, so a scoped row could never be read back + if row.Scope != "" { + return vmStore{}, fmt.Errorf("invalid inputs file '%s': scoped balances are not supported by the vm (account=%q scope=%q)", inputsPath, row.Account, row.Scope) + } + + amount := row.Amount + if amount == nil { + amount = new(big.Int) + } + store.balances[runtime.PairKey{Account: row.Account, Asset: row.Asset, Color: row.Color}] = amount + } + + for _, row := range inputs.Meta { + if row.Scope != "" { + return vmStore{}, fmt.Errorf("invalid inputs file '%s': scoped metadata is not supported by the vm (account=%q scope=%q)", inputsPath, row.Account, row.Scope) + } + + byKey, ok := store.meta[row.Account] + if !ok { + byKey = map[string]string{} + store.meta[row.Account] = byKey + } + byKey[row.Key] = row.Value + } + + return store, nil +} + +// loadVars resolves the vars pool from either the inputs file or an encoded +// .nvar blob. Both absent is legal: vm.Exec accepts a nil *Vars. +func loadVars(inputsPath string, inputs BytecodeInputsFile, varsPath string) (*vm.Vars, error) { + if inputs.VarsPool != nil && varsPath != "" { + return nil, fmt.Errorf("cannot use --vars together with the 'varsPool' key of '%s'", inputsPath) + } + + if varsPath != "" { + content, err := os.ReadFile(varsPath) + if err != nil { + return nil, err + } + vars, err := vm.DecodeVars(content) + if err != nil { + return nil, fmt.Errorf("failed to decode vars file '%s': %w", varsPath, err) + } + return &vars, nil + } + + if inputs.VarsPool == nil { + return nil, nil + } + + ints := make([]big.Int, len(inputs.VarsPool.Ints)) + for i, raw := range inputs.VarsPool.Ints { + if _, ok := ints[i].SetString(raw, 10); !ok { + return nil, fmt.Errorf("invalid inputs file '%s': varsPool.ints[%d] is not an integer: %q", inputsPath, i, raw) + } + } + + return &vm.Vars{ + StringsPool: inputs.VarsPool.Strings, + IntsPool: ints, + }, nil +} + +func bytecodeRun(bytecodePath string, opts BytecodeRunArgs) error { + bytecode, err := os.ReadFile(bytecodePath) + if err != nil { + return err + } + + program, err := vm.DecodeProgram(bytecode) + if err != nil { + return fmt.Errorf("failed to decode bytecode file '%s': %w", bytecodePath, err) + } + + inputsPath := opts.InputsPath + if inputsPath == "" { + inputsPath = bytecodePath + ".inputs.json" + } + + inputsContent, err := os.ReadFile(inputsPath) + if err != nil { + return err + } + + var inputs BytecodeInputsFile + err = json.Unmarshal(inputsContent, &inputs) + if err != nil { + return fmt.Errorf("failed to parse inputs file '%s' as JSON: %w", inputsPath, err) + } + + if err := validateInputRows(inputsPath, inputs.Balances, inputs.Meta); err != nil { + return err + } + + store, err := newVmStore(inputsPath, inputs) + if err != nil { + return err + } + + vars, err := loadVars(inputsPath, inputs, opts.VarsPath) + if err != nil { + return err + } + + result, execErr := vm.Exec(context.Background(), vm.NewVm(program), vars, store) + if execErr != nil { + fmt.Fprintln(os.Stderr, execErr.Error()) + return fmt.Errorf("execution failed") + } + + switch opts.OutFormatOpt { + case OutputFormatJson: + return showBytecodeJson(result) + case OutputFormatPretty: + return showBytecodePretty(result) + default: + return fmt.Errorf("invalid output format: %s", opts.OutFormatOpt) + } +} + +func showBytecodeJson(result runtime.ExecutionResult) error { + out, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("error marshaling result to JSON: %w", err) + } + + _, err = os.Stdout.Write(out) + return err +} + +func showBytecodePretty(result runtime.ExecutionResult) error { + fmt.Println("Postings:") + fmt.Println(interpreter.PrettyPrintPostings(result.Postings)) + + // interpreter.PrettyPrintMeta takes map[string]Value; the vm's metadata is + // already stringified + if len(result.Metadata) != 0 { + fmt.Println("Meta:") + fmt.Print(prettyPrintStringMeta(result.Metadata)) + } + + if len(result.AccountsMetadata) != 0 { + fmt.Println("Accounts meta:") + accounts := make([]string, 0, len(result.AccountsMetadata)) + for account := range result.AccountsMetadata { + accounts = append(accounts, account) + } + sort.Strings(accounts) + for _, account := range accounts { + fmt.Printf("@%s\n", account) + fmt.Print(prettyPrintStringMeta(result.AccountsMetadata[account])) + } + } + + return nil +} + +func prettyPrintStringMeta(meta map[string]string) string { + keys := make([]string, 0, len(meta)) + for key := range meta { + keys = append(keys, key) + } + sort.Strings(keys) + + var sb strings.Builder + for _, key := range keys { + fmt.Fprintf(&sb, " %s: %s\n", key, meta[key]) + } + return sb.String() +} + +func getBytecodeRunCmd() *cobra.Command { + opts := BytecodeRunArgs{} + + cmd := cobra.Command{ + Use: "bytecode-run", + Short: "Execute a bytecode file", + Long: `Execute a bytecode file, taking as inputs a json file containing balances, metadata and the vars pool. + +The inputs file has to have the same name as the bytecode file plus a ".inputs.json" suffix, for example: +bytecode-run folder/my-script.numb +will expect a 'folder/my-script.numb.inputs.json' file where to read inputs from. + +You can explicitly specify where the inputs file should be using the optional --inputs argument. + +Unlike 'run', variables are not passed by name: the bytecode addresses them by +their index in the vars pools, so they are given either as a "varsPool" key of the +inputs file ({"strings": [...], "ints": [...]}) or as an encoded vars blob via --vars. + +The bytecode format tracks an unstable instruction set and is not a public interface. +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + err := bytecodeRun(args[0], opts) + if err != nil { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return err + } + + return nil + }, + } + + cmd.Flags().StringVar(&opts.InputsPath, "inputs", "", "Path of a json file containing the inputs") + cmd.Flags().StringVar(&opts.VarsPath, "vars", "", "Path of a file containing an encoded vars payload") + cmd.Flags().StringVarP(&opts.OutFormatOpt, "output-format", "o", OutputFormatPretty, "Set the output format. Available options: pretty, json.") + + return &cmd +} diff --git a/internal/cmd/bytecode_run_test.go b/internal/cmd/bytecode_run_test.go new file mode 100644 index 00000000..636f73ab --- /dev/null +++ b/internal/cmd/bytecode_run_test.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "context" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/ir" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +const varsIR = ` + $asset = load_var(0) + set_current_asset($asset) + $amount = load_var(0) + $src = load_var(1) + $overdraft = load_var(1) + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = load_var(2) + send_to_account(account: $dest) +` + +func TestDefaultBytecodePath(t *testing.T) { + require.Equal(t, "folder/x.numb", defaultBytecodePath("folder/x.ir")) + require.Equal(t, "folder/x.numb", defaultBytecodePath("folder/x")) + require.Equal(t, "folder/x.num.numb", defaultBytecodePath("folder/x.num")) + // stdin has no path to derive an output name from + require.Equal(t, "-", defaultBytecodePath("-")) +} + +// Reading the IR from stdin must assemble to the same program as reading it +// from a file, and default to writing the bytecode to stdout. +func TestAssembleFromStdin(t *testing.T) { + dir := t.TempDir() + + stdin, err := os.Create(filepath.Join(dir, "stdin")) + require.NoError(t, err) + _, err = stdin.WriteString(varsIR) + require.NoError(t, err) + require.NoError(t, stdin.Close()) + stdin, err = os.Open(filepath.Join(dir, "stdin")) + require.NoError(t, err) + + captured, err := os.Create(filepath.Join(dir, "stdout")) + require.NoError(t, err) + + realStdin, realStdout := os.Stdin, os.Stdout + os.Stdin, os.Stdout = stdin, captured + err = assemble("-", AssembleArgs{}) + os.Stdin, os.Stdout = realStdin, realStdout + require.NoError(t, stdin.Close()) + require.NoError(t, captured.Close()) + require.NoError(t, err) + + written, err := os.ReadFile(filepath.Join(dir, "stdout")) + require.NoError(t, err) + fromStdin, err := vm.DecodeProgram(written) + require.NoError(t, err) + + instrs, irErrs := ir.Parse(varsIR) + require.Empty(t, irErrs) + expected, err := ir.Assemble(instrs) + require.NoError(t, err) + require.Equal(t, expected.Instructions, fromStdin.Instructions) + + // nothing was written next to a "-" path + _, err = os.Stat("-.numb") + require.True(t, os.IsNotExist(err)) +} + +// The file assemble writes must decode back to exactly what the assembler +// produced in memory. +func TestAssembleWritesADecodableProgram(t *testing.T) { + dir := t.TempDir() + irPath := filepath.Join(dir, "prog.ir") + require.NoError(t, os.WriteFile(irPath, []byte(varsIR), 0o644)) + + require.NoError(t, assemble(irPath, AssembleArgs{})) + + written, err := os.ReadFile(filepath.Join(dir, "prog.numb")) + require.NoError(t, err) + decoded, err := vm.DecodeProgram(written) + require.NoError(t, err) + + instrs, irErrs := ir.Parse(varsIR) + require.Empty(t, irErrs) + require.NoError(t, ir.Typecheck(instrs)) + expected, err := ir.Assemble(instrs) + require.NoError(t, err) + + require.Equal(t, expected.Instructions, decoded.Instructions) + require.Equal(t, expected.MaxRegString, decoded.MaxRegString) + require.Equal(t, expected.MaxRegInt, decoded.MaxRegInt) + require.Equal(t, expected.MaxRegPortion, decoded.MaxRegPortion) + require.Equal(t, expected.MaxRegBool, decoded.MaxRegBool) + // compared by content, not with require.Equal on the whole Program: this + // program has no constants, and an empty pool assembles to a nil slice but + // decodes to an empty one (parseStringsPool's make([]T, 0)) + require.Empty(t, decoded.StringsPool) + require.Empty(t, decoded.IntsPool) +} + +func TestAssembleToStdoutLeavesNoFile(t *testing.T) { + dir := t.TempDir() + irPath := filepath.Join(dir, "prog.ir") + require.NoError(t, os.WriteFile(irPath, []byte(varsIR), 0o644)) + + // the bytecode is binary: capture it instead of letting it into the test log + captured, err := os.Create(filepath.Join(dir, "stdout")) + require.NoError(t, err) + realStdout := os.Stdout + os.Stdout = captured + err = assemble(irPath, AssembleArgs{OutputPath: "-"}) + os.Stdout = realStdout + require.NoError(t, captured.Close()) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(dir, "prog.numb")) + require.True(t, os.IsNotExist(err)) + + written, err := os.ReadFile(filepath.Join(dir, "stdout")) + require.NoError(t, err) + _, err = vm.DecodeProgram(written) + require.NoError(t, err) +} + +func TestLoadVarsFromPool(t *testing.T) { + vars, err := loadVars("in.json", BytecodeInputsFile{ + VarsPool: &VarsPoolFile{ + Strings: []string{"USD/2"}, + // wider than an int64, so a naive json number would have lost it + Ints: []string{"123456789012345678901234567890"}, + }, + }, "") + require.NoError(t, err) + + expected, _ := new(big.Int).SetString("123456789012345678901234567890", 10) + require.Equal(t, []string{"USD/2"}, vars.StringsPool) + require.Equal(t, []big.Int{*expected}, vars.IntsPool) +} + +func TestLoadVarsAbsentIsNil(t *testing.T) { + vars, err := loadVars("in.json", BytecodeInputsFile{}, "") + require.NoError(t, err) + require.Nil(t, vars) +} + +func TestLoadVarsRejectsBothSources(t *testing.T) { + _, err := loadVars("in.json", BytecodeInputsFile{VarsPool: &VarsPoolFile{}}, "vars.nvar") + require.ErrorContains(t, err, "cannot use --vars together with") +} + +// The --vars path is the leader/node wire format: an encoded vm.Vars blob has to +// decode and drive the program to the same result as the inline pool. +func TestLoadVarsFromEncodedFile(t *testing.T) { + dir := t.TempDir() + varsPath := filepath.Join(dir, "prog.nvar") + encoded := vm.Vars{ + StringsPool: []string{"USD/2", "src", "dest"}, + IntsPool: []big.Int{*big.NewInt(10), *big.NewInt(0)}, + }.Encode() + require.NoError(t, os.WriteFile(varsPath, encoded, 0o644)) + + vars, err := loadVars("in.json", BytecodeInputsFile{}, varsPath) + require.NoError(t, err) + + instrs, irErrs := ir.Parse(varsIR) + require.Empty(t, irErrs) + program, err := ir.Assemble(instrs) + require.NoError(t, err) + + store, err := newVmStore("in.json", BytecodeInputsFile{ + Balances: interpreter.Balances{ + {Account: "src", Asset: "USD/2", Amount: big.NewInt(100)}, + }, + }) + require.NoError(t, err) + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), vars, store) + require.Nil(t, execErr) + require.Len(t, res.Postings, 1) + require.Equal(t, runtime.Posting{ + Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10), + }, res.Postings[0]) +} + +func TestLoadVarsRejectsAnUndecodableFile(t *testing.T) { + dir := t.TempDir() + varsPath := filepath.Join(dir, "prog.nvar") + require.NoError(t, os.WriteFile(varsPath, []byte("not a vars blob"), 0o644)) + + _, err := loadVars("in.json", BytecodeInputsFile{}, varsPath) + require.ErrorContains(t, err, "failed to decode vars file") +} + +// The store hands out balances the run state is free to mutate, so it must not +// alias the inputs. +func TestVmStoreReturnsACopyOfTheBalance(t *testing.T) { + amount := big.NewInt(100) + store, err := newVmStore("in.json", BytecodeInputsFile{ + Balances: interpreter.Balances{ + {Account: "src", Asset: "USD/2", Amount: amount}, + }, + }) + require.NoError(t, err) + + got, err := store.GetBalance(context.Background(), "src", "USD/2", "") + require.NoError(t, err) + require.Zero(t, got.Cmp(big.NewInt(100))) + + got.SetInt64(0) + require.Zero(t, amount.Cmp(big.NewInt(100))) +} + +func TestVmStoreUnknownAccountIsZeroNotAnError(t *testing.T) { + store, err := newVmStore("in.json", BytecodeInputsFile{}) + require.NoError(t, err) + + got, err := store.GetBalance(context.Background(), "nobody", "USD/2", "") + require.NoError(t, err) + require.Zero(t, got.Sign()) + + _, ok, err := store.GetMetadata(context.Background(), "nobody", "k") + require.NoError(t, err) + require.False(t, ok) +} + +func TestVmStoreRejectsScopedRows(t *testing.T) { + _, err := newVmStore("in.json", BytecodeInputsFile{ + Balances: interpreter.Balances{ + {Account: "src", Asset: "USD/2", Amount: big.NewInt(1), Scope: "reserve"}, + }, + }) + require.ErrorContains(t, err, "scoped balances are not supported by the vm") + + _, err = newVmStore("in.json", BytecodeInputsFile{ + Meta: interpreter.AccountsMetadata{ + {Account: "src", Key: "k", Value: "v", Scope: "reserve"}, + }, + }) + require.ErrorContains(t, err, "scoped metadata is not supported by the vm") +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1a4d8e19..ebd8f3de 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -30,6 +30,15 @@ func Execute(options CliOptions) { rootCmd.AddCommand(getTestInitCmd()) rootCmd.AddCommand(getRunCmd()) + // The ir/bytecode tooling tracks an unstable instruction set, so it stays out + // of --help unless NUMSCRIPT_EXPERIMENTAL_CLI is set. It is always registered + // and runnable, like lsp and mcp. + hidden := os.Getenv("NUMSCRIPT_EXPERIMENTAL_CLI") == "" + for _, experimentalCmd := range []*cobra.Command{getAssembleCmd(), getBytecodeRunCmd()} { + experimentalCmd.Hidden = hidden + rootCmd.AddCommand(experimentalCmd) + } + if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 8fef0907..37c4522d 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -29,6 +29,32 @@ type RunArgs struct { OutFormatOpt string } +// validateInputRows rejects a malformed inputs file before running anything: a +// balance list is a map keyed by (account, asset, color, scope) and a metadata +// list by (account, key, scope), so a repeated key is ambiguous. +func validateInputRows(inputsPath string, balances interpreter.Balances, meta interpreter.AccountsMetadata) error { + if dup, ok := balances.FirstDuplicate(); ok { + key := fmt.Sprintf("account=%q asset=%q", dup.Account, dup.Asset) + if dup.Color != "" { + key += fmt.Sprintf(" color=%q", dup.Color) + } + if dup.Scope != "" { + key += fmt.Sprintf(" scope=%q", dup.Scope) + } + return fmt.Errorf("invalid inputs file '%s': balances must not contain duplicate entries: duplicate entry for %s", inputsPath, key) + } + + if dup, ok := meta.FirstDuplicate(); ok { + key := fmt.Sprintf("account=%q key=%q", dup.Account, dup.Key) + if dup.Scope != "" { + key += fmt.Sprintf(" scope=%q", dup.Scope) + } + return fmt.Errorf("invalid inputs file '%s': metadata must not contain duplicate entries: duplicate entry for %s", inputsPath, key) + } + + return nil +} + func run(scriptPath string, opts RunArgs) error { numscriptContent, err := os.ReadFile(scriptPath) if err != nil { @@ -57,26 +83,8 @@ func run(scriptPath string, opts RunArgs) error { return fmt.Errorf("failed to parse inputs file '%s' as JSON: %w", inputsPath, err) } - // Reject a malformed inputs file before running anything: a balance list is a - // map keyed by (account, asset, color, scope), so a repeated key is ambiguous. - if dup, ok := inputs.Balances.FirstDuplicate(); ok { - key := fmt.Sprintf("account=%q asset=%q", dup.Account, dup.Asset) - if dup.Color != "" { - key += fmt.Sprintf(" color=%q", dup.Color) - } - if dup.Scope != "" { - key += fmt.Sprintf(" scope=%q", dup.Scope) - } - return fmt.Errorf("invalid inputs file '%s': balances must not contain duplicate entries: duplicate entry for %s", inputsPath, key) - } - - // Likewise, a metadata list is keyed by (account, key, scope). - if dup, ok := inputs.Meta.FirstDuplicate(); ok { - key := fmt.Sprintf("account=%q key=%q", dup.Account, dup.Key) - if dup.Scope != "" { - key += fmt.Sprintf(" scope=%q", dup.Scope) - } - return fmt.Errorf("invalid inputs file '%s': metadata must not contain duplicate entries: duplicate entry for %s", inputsPath, key) + if err := validateInputRows(inputsPath, inputs.Balances, inputs.Meta); err != nil { + return err } featureFlags := map[string]struct{}{} diff --git a/internal/compiler/bench_test.go b/internal/compiler/bench_test.go new file mode 100644 index 00000000..dddce1aa --- /dev/null +++ b/internal/compiler/bench_test.go @@ -0,0 +1,334 @@ +package compiler_test + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" +) + +// benchStore is a minimal vm.Store for the benchmarks. +type benchStore struct { + balances map[runtime.PairKey]*big.Int +} + +func (s benchStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { + if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + return v, nil + } + return new(big.Int), nil +} + +func (benchStore) GetMetadata(ctx context.Context, k, v string) (string, bool, error) { + return "", false, nil +} + +type runtimeStoreAdapter struct { + store vm.Store +} + +func (s runtimeStoreAdapter) GetBalance( + account string, + asset string, + color string, +) (*big.Int, error) { + return s.store.GetBalance(context.Background(), account, asset, color) +} + +// Both benchmarks run the SAME program with the same starting balance; only the +// per-iteration RUN is measured (parse/compile/assemble happen once, up front). +const benchSrc = `send [USD/2 10] ( + source = @src + destination = @dest +)` + +// BenchmarkTreeWalker measures the tree-walking interpreter on a pre-parsed AST. +func BenchmarkTreeWalker(b *testing.B) { + parsed := parser.Parse(benchSrc) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + store := interpreter.StaticStore{ + Balances: interpreter.Balances{ + {Account: "src", Asset: "USD/2", Amount: big.NewInt(100)}, + }, + } + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := interpreter.RunProgram(ctx, parsed.Value, nil, store, nil) + if err != nil { + b.Fatalf("run: %v", err) + } + } +} + +// BenchmarkRuntimeBaseline is the floor: it drives runtime.RunState directly, +// performing exactly the funds operations the program lowers to — with no AST +// walk and no bytecode dispatch. It reuses one RunState (like the VM reuses its +// runstate) and hoists the constants (the compiler would pool them). The gap +// between this and BenchmarkCompiledVM is the VM's dispatch/register overhead; +// the gap to BenchmarkTreeWalker is the interpreter's front-end overhead. +func BenchmarkRuntimeBaseline(b *testing.B) { + store := runtimeStoreAdapter{ + store: benchStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}, + } + + rs := runtime.New(store) + + ten := big.NewInt(10) // the sent amount / pull cap + zero := big.NewInt(0) // bounded overdraft of 0 + pulled := new(big.Int) // reused output register + dest := "dest" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rs.Reset(store) + rs.SetCurrentAsset("USD/2") + _ = rs.Pull(pulled, "src", "", ten, zero, "") + _ = pulled.Cmp(ten) // CheckEnoughFunds + _ = rs.SendUncapped(&dest, "", nil) + _ = rs.GetPostings() + } +} + +// BenchmarkCompiledVM measures the compiled bytecode on the register VM, reusing +// a single Vm instance across iterations (its register banks are not realloc'd). +func BenchmarkCompiledVM(b *testing.B) { + parsed := parser.Parse(benchSrc) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + _, program, err := compiler.Compile(parsed.Value, nil) + if err != nil { + b.Fatalf("compile: %v", err) + } + store := benchStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) // reused across iterations + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := vm.Exec(context.Background(), machine, nil, store) + if err != nil { + b.Fatalf("exec: %v", err) + } + } +} + +// --- Capped inorder script: `{ @a ; max [USD/2 5] from @b ; @c }` ----------- +// Same methodology as above, on a more representative script (inorder traversal, +// a `max` cap (a min, i.e. lt_int + copies), running total, and an early-exit +// jump). Balances: +// a=3, b=100 (capped to 5), c=100 → pulls 3 / 5 / 2. +const benchSrcCapped = `send [USD/2 10] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest +)` + +func BenchmarkTreeWalkerCapped(b *testing.B) { + parsed := parser.Parse(benchSrcCapped) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + store := interpreter.StaticStore{ + Balances: interpreter.Balances{ + {Account: "a", Asset: "USD/2", Amount: big.NewInt(3)}, + {Account: "b", Asset: "USD/2", Amount: big.NewInt(100)}, + {Account: "c", Asset: "USD/2", Amount: big.NewInt(100)}, + }, + } + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := interpreter.RunProgram(ctx, parsed.Value, nil, store, nil) + if err != nil { + b.Fatalf("run: %v", err) + } + } +} + +func cappedStore() benchStore { + return benchStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(3), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} +} + +// BenchmarkRuntimeBaselineCapped is the floor: it drives runtime.RunState +// directly, performing the funds ops the capped-inorder script lowers to (with +// the cap/running-total/early-exit arithmetic done inline on reused big.Ints) — +// no AST walk, no bytecode dispatch. RunState reused across iterations. +func BenchmarkRuntimeBaselineCapped(b *testing.B) { + store := runtimeStoreAdapter{store: cappedStore()} + rs := runtime.New(store) + + zero := big.NewInt(0) + ten := big.NewInt(10) + five := big.NewInt(5) + remaining := new(big.Int) + capB := new(big.Int) + pulled := new(big.Int) + total := new(big.Int) + dest := "dest" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rs.Reset(store) + rs.SetCurrentAsset("USD/2") + total.SetInt64(0) + remaining.Set(ten) // inorder cap = copy(amount) + + // @a (cap = remaining) + _ = rs.Pull(pulled, "a", "", remaining, zero, "") + total.Add(total, pulled) + remaining.Sub(remaining, pulled) + + if remaining.Sign() != 0 { // is_zero(remaining) + jmp_if_true + // max [USD/2 5] from @b -> cap = min(5, remaining) + if five.Cmp(remaining) < 0 { + capB.Set(five) + } else { + capB.Set(remaining) + } + _ = rs.Pull(pulled, "b", "", capB, zero, "") + total.Add(total, pulled) + remaining.Sub(remaining, pulled) + + if remaining.Sign() != 0 { + _ = rs.Pull(pulled, "c", "", remaining, zero, "") // @c (cap = remaining) + total.Add(total, pulled) + } + } + + _ = total.Cmp(ten) // check_enough_funds + _ = rs.SendUncapped(&dest, "", nil) + _ = rs.GetPostings() + } +} + +func BenchmarkCompiledVMCapped(b *testing.B) { + parsed := parser.Parse(benchSrcCapped) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + _, program, err := compiler.Compile(parsed.Value, nil) + if err != nil { + b.Fatalf("compile: %v", err) + } + store := cappedStore() + + machine := vm.NewVm(program) // reused across iterations + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := vm.Exec(context.Background(), machine, nil, store) + if err != nil { + b.Fatalf("exec: %v", err) + } + } +} + +// --- Allotment scripts ------------------------------------------------------ +// Ported from feat/exp/optimize-vm so the before/after of decomposing the +// allotment split into pure ops is measurable on this branch. Same methodology. + +// benchCompiledVM is the shape the two benchmarks above open-code: compile once, +// reuse one Vm, measure only the run. +func benchCompiledVM(b *testing.B, src string, store benchStore) { + b.Helper() + + parsed := parser.Parse(src) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + _, program, err := compiler.Compile(parsed.Value, nil) + if err != nil { + b.Fatalf("compile: %v", err) + } + + machine := vm.NewVm(program) // reused across iterations + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := vm.Exec(context.Background(), machine, nil, store) + if err != nil { + b.Fatalf("exec: %v", err) + } + } +} + +// Fan-out allotment: 1 source -> {1/2 @a; 1/2 @b}. Exercises the allotment +// split and the queue drain across two capped sends. +const benchSrcAllotment = `send [USD/2 100] ( + source = @src + destination = { + 1/2 to @a + 1/2 to @b + } +)` + +func BenchmarkCompiledVMAllotment(b *testing.B) { + benchCompiledVM(b, benchSrcAllotment, benchStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }}) +} + +// Thirds: the case where the flooring leftover is non-zero, so the fixup pass +// actually runs (100 -> 34/33/33). +const benchSrcAllotmentThirds = `send [USD/2 100] ( + source = @src + destination = { + 1/3 to @a + 1/3 to @b + remaining to @c + } +)` + +func BenchmarkCompiledVMAllotmentThirds(b *testing.B) { + benchCompiledVM(b, benchSrcAllotmentThirds, benchStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }}) +} + +// Fan-in: {1/3 from @a; 1/3 from @b; 1/3 from @c} -> @dest. Allotment on the +// source side, no early-exit jump. +const benchSrcFanIn = `send [USD/2 30] ( + source = { + 1/3 from @a + 1/3 from @b + 1/3 from @c + } + destination = @dest +)` + +func BenchmarkCompiledVMFanIn(b *testing.B) { + benchCompiledVM(b, benchSrcFanIn, benchStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) +} diff --git a/internal/compiler/compile_error_test.go b/internal/compiler/compile_error_test.go new file mode 100644 index 00000000..ba5a14af --- /dev/null +++ b/internal/compiler/compile_error_test.go @@ -0,0 +1,243 @@ +package compiler + +// White-box tests asserting the concrete CompilerError produced for invalid +// programs. They call compileProgramToIR directly, since the public Compile +// stringifies the error and would lose the type. + +import ( + "testing" + + "github.com/formancehq/numscript/internal/flags" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" + "github.com/stretchr/testify/require" +) + +func TestE2E_RejectsUnboundVariable(t *testing.T) { + parsed := parser.Parse(`send [C 10] (source = $undeclared destination = @d)`) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, TypeError{}, cErr) + require.IsType(t, typecheck.UnboundVariable{}, cErr.(TypeError).Kind) +} + +func TestE2E_RejectsTypeMismatch(t *testing.T) { + parsed := parser.Parse(`vars { string $s } send [C 10] (source = $s destination = @d)`) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, TypeError{}, cErr) + require.IsType(t, typecheck.TypeMismatch{}, cErr.(TypeError).Kind) +} + +func TestE2E_RejectsMetaOutsideVarOrigin(t *testing.T) { + // meta() is only supported as a direct variable origin; nested in an + // expression it must be a compile error, not a panic. + parsed := parser.Parse(` + #![feature("experimental-mid-script-function-call")] + vars { + account $a + number $n = meta($a, "k") + 1 + } + send [C $n] (source = @world destination = @d) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, InvalidMetaPosition{}, cErr) +} + +func TestE2E_RejectsNonCastableInterpVar(t *testing.T) { + // a monetary var has no string form: interpolating it must be a compile + // error (matching the interpreter's runtime CannotCastToString), not a panic. + parsed := parser.Parse(` + #![feature("experimental-account-interpolation")] + vars { monetary $m } + set_tx_meta("k", @acc:$m) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, CannotCastToString{}, cErr) + require.Equal(t, typecheck.TypeMonetary, cErr.(CannotCastToString).Type) +} + +func TestE2E_AllotmentDuplicateRemaining(t *testing.T) { + parsed := parser.Parse(` + send [USD/2 100] ( + source = @world + destination = { + remaining to @a + remaining to @b + } + ) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, DuplicateRemaining{}, cErr) +} + +// --- feature flags + +// each case is a construct gated behind a feature flag: compiling it without the +// flag must fail, and compiling it with the flag must get past the gate. +func TestFeatureFlagGating(t *testing.T) { + testCases := []struct { + name string + flag flags.FeatureFlag + src string + }{ + { + name: "oneof in source", + flag: flags.ExperimentalOneofFeatureFlag, + src: `send [C 10] ( + source = oneof { @a @b } + destination = @d + )`, + }, + { + name: "oneof in destination", + flag: flags.ExperimentalOneofFeatureFlag, + src: `send [C 10] ( + source = @world + destination = oneof { + max [C 3] to @a + remaining to @b + } + )`, + }, + { + name: "account interpolation", + flag: flags.ExperimentalAccountInterpolationFlag, + src: `vars { string $s } + send [C 10] (source = @world destination = @dest:$s)`, + }, + { + name: "mid-script function call", + flag: flags.ExperimentalMidScriptFunctionCall, + src: `send balance(@a, C) (source = @world destination = @d)`, + }, + { + name: "overdraft function", + flag: flags.ExperimentalOverdraftFunctionFeatureFlag, + src: `vars { monetary $m = overdraft(@a, C) } + send $m (source = @world destination = @d)`, + }, + { + name: "get_asset function", + flag: flags.ExperimentalGetAssetFunctionFeatureFlag, + src: `vars { monetary $m asset $a = get_asset($m) } + send [$a 10] (source = @world destination = @d)`, + }, + { + name: "get_amount function", + flag: flags.ExperimentalGetAmountFunctionFeatureFlag, + src: `vars { monetary $m number $n = get_amount($m) } + send [C $n] (source = @world destination = @d)`, + }, + { + name: "asset colors", + flag: flags.ExperimentalAssetColors, + src: `send [C 10] (source = @a \ "RED" destination = @d)`, + }, + { + name: "asset scaling", + flag: flags.AssetScaling, + src: `send [C 10] ( + source = @src with scaling through @swap + destination = @d + )`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsed := parser.Parse(tc.src) + require.Empty(t, parsed.Errors) + + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, ExperimentalFeature{}, cErr) + require.Equal(t, tc.flag, cErr.(ExperimentalFeature).FlagName) + + // with the flag on, whatever comes back must not be about the flag + // (scaling still hits FeatureNotImplemented) + _, cErr = compileProgramToIR(parsed.Value, map[string]struct{}{tc.flag: {}}) + _, stillGated := cErr.(ExperimentalFeature) + require.False(t, stillGated, "still gated with the flag on: %v", cErr) + }) + } +} + +// a function call that *is* the variable's origin is not a mid-script call +func TestFnCallAsVarOriginIsNotMidScript(t *testing.T) { + parsed := parser.Parse(` + vars { monetary $m = balance(@a, C) } + send $m (source = @world destination = @d) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.Nil(t, cErr) +} + +// ... but one nested inside the origin expression is +func TestNestedFnCallInVarOriginIsMidScript(t *testing.T) { + parsed := parser.Parse(` + vars { monetary $m = balance(@a, C) + balance(@b, C) } + send $m (source = @world destination = @d) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, ExperimentalFeature{}, cErr) + require.Equal(t, flags.ExperimentalMidScriptFunctionCall, cErr.(ExperimentalFeature).FlagName) +} + +// #![feature(..)] in the source enables a flag the host didn't pass +func TestInSourceFeatureDeclaration(t *testing.T) { + parsed := parser.Parse(` + #![feature("experimental-oneof")] + send [C 10] ( + source = oneof { @a @b } + destination = @d + ) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.Nil(t, cErr) +} + +func TestInSourceFeatureDeclarationRejectsUnknownFlag(t *testing.T) { + parsed := parser.Parse(` + #![feature("not-a-flag")] + send [C 10] (source = @world destination = @d) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToIR(parsed.Value, nil) + require.IsType(t, InvalidFeature{}, cErr) + require.Equal(t, "not-a-flag", cErr.(InvalidFeature).Feature) +} + +// Every CompilerError must carry a human-readable message: CompilerError is +// parser.Ranged + compileError(), so a type missing Error() still satisfies the +// interface and Compile's fmt.Errorf("%v") would print the raw struct instead. +func TestCompilerErrorMessages(t *testing.T) { + testCases := []struct { + name string + err CompilerError + msg string + }{ + {"UnboundVar", UnboundVar{Var: "x"}, "the variable '$x' was not declared"}, + {"TypeError", TypeError{Kind: typecheck.UnboundVariable{Name: "x"}}, "The variable '$x' was not declared"}, + {"InvalidUncappedSource", InvalidUncappedSource{}, "cannot take all balance of an unbounded source"}, + {"DuplicateRemaining", DuplicateRemaining{}, "a 'remaining' clause should be the last in an allotment expression"}, + {"InvalidMetaPosition", InvalidMetaPosition{}, "meta() is only allowed as a variable origin"}, + {"CannotCastToString", CannotCastToString{Type: typecheck.TypeMonetary}, "cannot cast a value of type monetary to string"}, + {"FeatureNotImplemented", FeatureNotImplemented{Feature: "scaling"}, "internal error: feature not implemented: scaling"}, + {"ExperimentalFeature", ExperimentalFeature{FlagName: flags.ExperimentalAssetColors}, "You need the 'experimental-asset-colors' feature flag to enable it"}, + {"InvalidFeature", InvalidFeature{Feature: "nope"}, "Invalid feature: nope"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err, ok := tc.err.(error) + require.True(t, ok, "%T does not implement error", tc.err) + require.Contains(t, err.Error(), tc.msg) + }) + } +} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go new file mode 100644 index 00000000..26e400b6 --- /dev/null +++ b/internal/compiler/compiler.go @@ -0,0 +1,1362 @@ +package compiler + +import ( + "fmt" + "maps" + "math/big" + "slices" + + "github.com/formancehq/numscript/internal/builtins" + "github.com/formancehq/numscript/internal/flags" + "github.com/formancehq/numscript/internal/ir" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" + "github.com/formancehq/numscript/internal/utils" + "github.com/formancehq/numscript/internal/vm" +) + +// Compile lowers a parsed program to the VarsEncoder that turns a json var +// payload into the vm.Vars the program expects, plus the vm.Program itself. +// +// featureFlags is the set of experimental features the host allows; a construct +// gated behind a flag that isn't in the set fails compilation. As in +// interpreter.RunProgram, the script's own #![feature(..)] declarations are +// unioned in. +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) + } + + if err := ir.Typecheck(compiled.instructions); err != nil { + return VarsEncoder{}, vm.Program{}, err + } + + prog, err := ir.Assemble(compiled.instructions) + if err != nil { + return VarsEncoder{}, vm.Program{}, err + } + + return compiled.varsEncoder, prog, nil +} + +type compiledProgramIR struct { + instructions []ir.Instr + varsEncoder VarsEncoder +} + +type state struct { + ir.Builder + + vars map[string]value + exprTypes map[parser.ValueExpr]typecheck.Type + featureFlags map[string]struct{} + // set by compileSentValue before any source/destination is compiled; nil + // until then, so compileCapAmount can't silently assert against register 0. + currentAssetReg *ir.Reg + + nextIntVar int + nextStrVar int + varDecls []varDecl + + // holds worldAccount; see pullFromAccount + worldReg ir.Reg +} + +// The unbounded account. The VM knows nothing about it: a source account is a +// register, so the comparison is compiled, not built in. +const worldAccount = "world" + +// Every flag in flags.AllFlags has a check site below except +// ExperimentalScopedFunction: scoped() isn't in typecheck's builtin table, so a +// call to it is already rejected as an unknown function. +func (st *state) checkFeatureFlag(rng parser.Range, flag flags.FeatureFlag) CompilerError { + if _, ok := st.featureFlags[flag]; ok { + return nil + } + return ExperimentalFeature{Range: rng, FlagName: flag} +} + +// pushInstructionWithDestErr is PushWithDest in the shape compileExpr returns. +func (st *state) pushInstructionWithDestErr(getInstr func(dest ir.Reg) ir.Instr) (ir.Reg, CompilerError) { + return st.PushWithDest(getInstr), nil +} + +func (st *state) compileAllot(amount ir.Reg, allotments []parser.AllotmentValue) ([]ir.Reg, CompilerError) { + n := len(allotments) + portions := make([]ir.Reg, n) + remainingIdx := -1 + for i, al := range allotments { + switch al := al.(type) { + case *parser.ValueExprAllotment: + p, err := st.compileExpr(al.Value) + if err != nil { + return nil, err + } + portions[i] = p + case *parser.RemainingAllotment: + if remainingIdx != -1 { + return nil, DuplicateRemaining{Range: al.Range} + } + remainingIdx = i + default: + utils.NonExhaustiveMatchPanic[any](al) + } + } + + leftover := st.compilePortionOne() + for i := range allotments { + if i == remainingIdx { + continue + } + prev, pi := leftover, portions[i] + leftover = st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpSubPortion{}, Left: prev, Right: pi, Dest: dest} + }) + } + + st.Push(ir.AssertLeftover{Portion: leftover, Exact: remainingIdx == -1}) + if remainingIdx != -1 { + portions[remainingIdx] = leftover + } + + return st.compileAllotmentSplit(amount, portions), nil +} + +// TODO properly review claude-generated compileAllotmentSplit + +// compileAllotmentSplit writes the amount split across the portions: one int +// register per portion, summing exactly to amount. It expects the portions to +// sum to 1, which is what the assert_leftover emitted by the caller establishes. +// +// Each share is floor(portion * amount); flooring loses strictly less than one +// unit per share, so the shortfall is under len(portions) and a single +// front-to-back pass handing out one unit each closes it. That order is +// observable — 100 by thirds is 34/33/33, not 33/33/34. +func (st *state) compileAllotmentSplit(amount ir.Reg, portions []ir.Reg) []ir.Reg { + n := len(portions) + dest := make([]ir.Reg, n) + + amountPortion := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIntToPortion{}, Arg: amount, Dest: dest} + }) + + // total accumulates the floored shares; it starts as a copy of the first one + // rather than a zero, which saves a load + var total ir.Reg + for i, portion := range portions { + product := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpMulPortion{}, Left: portion, Right: amountPortion, Dest: dest} + }) + dest[i] = st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpPortionToInt{}, Arg: product, Dest: dest} + }) + + if i == 0 { + total = st.PushWithDest(func(t ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIntCopy{}, Arg: dest[0], Dest: t} + }) + continue + } + st.Push(ir.BinaryOp{Op: ir.OpAddInt{}, Left: total, Right: dest[i], Dest: total}) + } + + // The shortfall is at most n-1, so the last share never receives a unit and + // its block would be dead. The jumps go forward to one shared exit, which is + // what lets this be a straight line: the assembler rejects backward jumps. + if n > 1 { + one := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{Value: *big.NewInt(1), Dest: dest} + }) + done := st.FreshLabel("allot_end") + + for i := 0; i < n-1; i++ { + short := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpLtInt{}, Left: total, Right: amount, Dest: dest} + }) + st.Push(ir.JmpIfFalse{Cond: short, Target: done}) + st.Push(ir.BinaryOp{Op: ir.OpAddInt{}, Left: dest[i], Right: one, Dest: dest[i]}) + st.Push(ir.BinaryOp{Op: ir.OpAddInt{}, Left: total, Right: one, Dest: total}) + } + + st.Push(ir.LabelMarker{Label: done}) + } + + return dest +} + +func (st *state) compileCapAmount(monExpr parser.ValueExpr) (ir.Reg, CompilerError) { + mon, err := st.compileMonetaryExpr(monExpr) + if err != nil { + return 0, err + } + if st.currentAssetReg == nil { + panic("compileCapAmount: no current asset (compileSentValue must run first)") + } + st.Push(ir.AssertSameAsset{Left: mon.Asset, Right: *st.currentAssetReg}) + return mon.Amount, nil +} + +func (st *state) compilePortionOne() ir.Reg { + one := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{Value: *big.NewInt(1), Dest: dest} + }) + return st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpMakePortion{}, Left: one, Right: one, Dest: dest} + }) +} + +// compileExpr compiles a non-monetary expression into the single register its +// type maps to. Monetary-typed expressions go to compileMonetaryExpr instead, +// since a monetary needs two registers. +func (st *state) compileExpr(expr parser.ValueExpr) (ir.Reg, CompilerError) { + if st.exprTypes[expr] == typecheck.TypeMonetary { + panic("compileExpr: monetary expression (use compileMonetaryExpr)") + } + + switch expr := expr.(type) { + case *parser.AssetLiteral: + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.LoadStr{ + Value: expr.Asset, + Dest: dest, + } + }) + + case *parser.StringLiteral: + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.LoadStr{ + Value: expr.String, + Dest: dest, + } + }) + + case *parser.NumberLiteral: + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{ + Value: *expr.Number, + Dest: dest, + } + }) + + case *parser.AccountInterpLiteral: + var parts []ir.Reg + hasVar := false + for _, part := range expr.Parts { + switch part := part.(type) { + case parser.AccountTextPart: + dest := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadStr{ + Value: part.Name, + Dest: dest, + } + }) + parts = append(parts, dest) + case *parser.Variable: + if err := st.checkFeatureFlag(part.Range, flags.ExperimentalAccountInterpolationFlag); err != nil { + return 0, err + } + hasVar = true + // reject before compiling, so a non-castable part doesn't reach + // compileExpr (which only handles non-monetary expressions) + t := st.exprTypes[part] + switch t { + case typecheck.TypeAccount, typecheck.TypeString, typecheck.TypeNumber: + default: + return 0, CannotCastToString{Range: part.GetRange(), Type: t} + } + r, err := st.compileExpr(part) + if err != nil { + return 0, err + } + if t == typecheck.TypeNumber { + r = st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIntToString{}, Arg: r, Dest: dest} + }) + } + parts = append(parts, r) + } + } + + acc := parts[0] + for _, part := range parts[1:] { + left, right := acc, part + acc = st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpAddString{}, Left: left, Right: right, Dest: dest} + }) + } + // an interpolated var can inject chars that make the name ill-formed; + // all-text literals are valid by construction, so skip the check + if hasVar { + st.Push(ir.AssertValidAccount{Account: acc}) + } + return acc, nil + + case *parser.Variable: + v, ok := st.vars[expr.Name] + if !ok { + return 0, UnboundVar{Range: expr.Range, Var: expr.Name} + } + return v.Reg, nil + + case *parser.PercentageLiteral: + // e.g. 50% -> portion 50/100; mk_portion reduces via SetFrac + ratio := expr.ToRatio() + numReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{Value: *ratio.Num(), Dest: dest} + }) + denReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{Value: *ratio.Denom(), Dest: dest} + }) + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpMakePortion{}, Left: numReg, Right: denReg, Dest: dest} + }) + + case *parser.BinaryInfix: + leftReg, err := st.compileExpr(expr.Left) + if err != nil { + return 0, err + } + rightReg, err := st.compileExpr(expr.Right) + if err != nil { + return 0, err + } + + switch expr.Operator { + case parser.InfixOperatorDiv: + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpMakePortion{}, Left: leftReg, Right: rightReg, Dest: dest} + }) + + case parser.InfixOperatorPlus: + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpAddInt{}, Left: leftReg, Right: rightReg, Dest: dest} + }) + + case parser.InfixOperatorMinus: + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpSubInt{}, Left: leftReg, Right: rightReg, Dest: dest} + }) + + default: + panic("TODO compileExpr binary op " + string(expr.Operator)) + } + + case *parser.Prefix: + switch expr.Operator { + case parser.PrefixOperatorMinus: + argReg, err := st.compileExpr(expr.Expr) + if err != nil { + return 0, err + } + return st.pushInstructionWithDestErr(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpNegInt{}, Arg: argReg, Dest: dest} + }) + + default: + panic("TODO compileExpr prefix op " + string(expr.Operator)) + } + + case *parser.FnCall: + return st.compileFnCall(expr, false) + + default: + return utils.NonExhaustiveMatchPanic[ir.Reg](expr), nil + } +} + +// compileFnCall takes isVarOrigin to tell apart the two positions the interpreter +// distinguishes: a call that *is* a variable's origin expression, versus one +// nested anywhere else (which needs the mid-script-function-call flag). +func (st *state) compileFnCall(expr *parser.FnCall, isVarOrigin bool) (ir.Reg, CompilerError) { + if !isVarOrigin { + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalMidScriptFunctionCall); err != nil { + return 0, err + } + } + + switch expr.Caller.Name { + case builtins.GetAmount: + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalGetAmountFunctionFeatureFlag); err != nil { + return 0, err + } + mon, err := st.compileMonetaryExpr(expr.Args[0]) + if err != nil { + return 0, err + } + return mon.Amount, nil + + case builtins.GetAsset: + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalGetAssetFunctionFeatureFlag); err != nil { + return 0, err + } + mon, err := st.compileMonetaryExpr(expr.Args[0]) + if err != nil { + return 0, err + } + return mon.Asset, nil + + case builtins.Meta: + return 0, InvalidMetaPosition{Range: expr.Range} + + default: + panic("TODO compileExpr fn call " + expr.Caller.Name) + } +} + +// compileMonetaryExpr compiles a monetary-typed expression into the (asset, +// amount) register pair. Which expressions reach here is decided by +// st.exprTypes; compileExpr rejects monetary-typed ones. +func (st *state) compileMonetaryExpr(expr parser.ValueExpr) (monetaryValue, CompilerError) { + switch expr := expr.(type) { + case *parser.MonetaryLiteral: + assetReg, err := st.compileExpr(expr.Asset) + if err != nil { + return monetaryValue{}, err + } + amtReg, err := st.compileExpr(expr.Amount) + if err != nil { + return monetaryValue{}, err + } + return monetaryValue{Asset: assetReg, Amount: amtReg}, nil + + case *parser.Variable: + v, ok := st.vars[expr.Name] + if !ok { + return monetaryValue{}, UnboundVar{Range: expr.Range, Var: expr.Name} + } + if v.Mon == nil { + panic("compileMonetaryExpr: $" + expr.Name + " is not a monetary") + } + return *v.Mon, nil + + case *parser.BinaryInfix: + left, err := st.compileMonetaryExpr(expr.Left) + if err != nil { + return monetaryValue{}, err + } + right, err := st.compileMonetaryExpr(expr.Right) + if err != nil { + return monetaryValue{}, err + } + st.Push(ir.AssertSameAsset{Left: left.Asset, Right: right.Asset}) + + var op ir.BinKind + switch expr.Operator { + case parser.InfixOperatorPlus: + op = ir.OpAddInt{} + case parser.InfixOperatorMinus: + op = ir.OpSubInt{} + default: + panic("TODO compileMonetaryExpr binary op " + string(expr.Operator)) + } + amount := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: op, Left: left.Amount, Right: right.Amount, Dest: dest} + }) + // the assert above makes left vs right immaterial + return monetaryValue{Asset: left.Asset, Amount: amount}, nil + + case *parser.Prefix: + if expr.Operator != parser.PrefixOperatorMinus { + panic("TODO compileMonetaryExpr prefix op " + string(expr.Operator)) + } + arg, err := st.compileMonetaryExpr(expr.Expr) + if err != nil { + return monetaryValue{}, err + } + amount := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpNegInt{}, Arg: arg.Amount, Dest: dest} + }) + return monetaryValue{Asset: arg.Asset, Amount: amount}, nil + + case *parser.FnCall: + return st.compileMonetaryFnCall(expr, false) + + default: + return utils.NonExhaustiveMatchPanic[monetaryValue](expr), nil + } +} + +// compileMonetaryFnCall handles the builtins that return a monetary. isVarOrigin +// carries the same meaning as in compileFnCall. +func (st *state) compileMonetaryFnCall(expr *parser.FnCall, isVarOrigin bool) (monetaryValue, CompilerError) { + if !isVarOrigin { + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalMidScriptFunctionCall); err != nil { + return monetaryValue{}, err + } + } + + switch expr.Caller.Name { + case builtins.Balance: + accountReg, err := st.compileExpr(expr.Args[0]) + if err != nil { + return monetaryValue{}, err + } + assetReg, err := st.compileExpr(expr.Args[1]) + if err != nil { + return monetaryValue{}, err + } + balReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.FetchBalance{Dest: dest, Account: accountReg, Asset: assetReg} + }) + st.Push(ir.AssertNonNegativeBalance{Balance: balReg, Account: accountReg}) + return monetaryValue{Asset: assetReg, Amount: balReg}, nil + + case builtins.Overdraft: + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalOverdraftFunctionFeatureFlag); err != nil { + return monetaryValue{}, err + } + accountReg, err := st.compileExpr(expr.Args[0]) + if err != nil { + return monetaryValue{}, err + } + assetReg, err := st.compileExpr(expr.Args[1]) + if err != nil { + return monetaryValue{}, err + } + balReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.FetchBalance{Dest: dest, Account: accountReg, Asset: assetReg} + }) + zeroReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{Value: *big.NewInt(0), Dest: dest} + }) + // overdraft = max(0, -balance) = -min(balance, 0) + minReg := st.minInt(balReg, zeroReg) + negReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpNegInt{}, Arg: minReg, Dest: dest} + }) + return monetaryValue{Asset: assetReg, Amount: negReg}, nil + + case builtins.Meta: + return monetaryValue{}, InvalidMetaPosition{Range: expr.Range} + + default: + panic("TODO compileMonetaryExpr fn call " + expr.Caller.Name) + } +} + +// compileColor returns nil when the source has no color clause: PullAccount with +// no color pulls the uncolored balance, same as an empty color string. +func (st *state) compileColor(colorExpr parser.ValueExpr) (*ir.Reg, CompilerError) { + if colorExpr == nil { + return nil, nil + } + if err := st.checkFeatureFlag(colorExpr.GetRange(), flags.ExperimentalAssetColors); err != nil { + return nil, err + } + reg, err := st.compileExpr(colorExpr) + if err != nil { + return nil, err + } + st.Push(ir.AssertValidColor{Color: reg}) + return ®, nil +} + +// pullFromAccount emits the pull of a source account, including the @world +// check. The account is a register — it can come from a var, an interpolation or +// metadata — so the check cannot be decided here and becomes a run-time branch: +// +// $eq = str_eq($account, $world) +// jmp_if_false($eq, #not_world) +// $pulled = pull_account(...) // no overdraft operand: unbounded +// jmp(#pull_end) +// #not_world +// $pulled = pull_account(..., overdraft: $od) +// #pull_end +// +// Both arms write the same dest, which the register typechecker allows because +// the type doesn't change. When overdraftReg is nil the source is unbounded for +// every account, so the two arms would be identical and the branch is skipped. +// A literal @world still gets the branch; collapsing it is a peephole's job +// (const-fold str_eq, then drop the dead arm). +func (st *state) pullFromAccount(accReg ir.Reg, capReg, overdraftReg, colorReg *ir.Reg) ir.Reg { + pull := func(dest ir.Reg, overdraft *ir.Reg) ir.Instr { + return ir.PullAccount{ + Dest: dest, + Account: accReg, + Cap: capReg, + Overdraft: overdraft, + Color: colorReg, + } + } + + if overdraftReg == nil { + return st.PushWithDest(func(dest ir.Reg) ir.Instr { return pull(dest, nil) }) + } + + isWorld := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpStrEq{}, Left: accReg, Right: st.worldReg, Dest: dest} + }) + notWorldLabel := st.FreshLabel("not_world") + endLabel := st.FreshLabel("pull_end") + + st.Push(ir.JmpIfFalse{Cond: isWorld, Target: notWorldLabel}) + // an uncapped context reaches this arm with no cap and no overdraft, which is + // the InvalidUncappedSource case: taking *all* of an unbounded source + pulledReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { return pull(dest, nil) }) + st.Push(ir.Jmp{Target: endLabel}) + + st.Push(ir.LabelMarker{Label: notWorldLabel}) + st.Push(pull(pulledReg, overdraftReg)) + st.Push(ir.LabelMarker{Label: endLabel}) + + return pulledReg +} + +// minInt writes min(leftReg, rightReg) into a fresh register. There is no min +// opcode, so it is a comparison, a copy and a branch. Speculatively copying the +// left operand first saves the `jmp` the else arm would otherwise need: +// +// $min = int_copy($left) +// $lt = lt_int($left, $right) +// jmp_if_true($lt, #min_end) ; left is already the answer +// $min = int_copy($right) +// #min_end +// +// That form is only correct because the dest is freshly allocated: an aliased +// dest would clobber $right before the else arm reads it. +func (st *state) minInt(leftReg, rightReg ir.Reg) ir.Reg { + minReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIntCopy{}, Arg: leftReg, Dest: dest} + }) + lt := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpLtInt{}, Left: leftReg, Right: rightReg, Dest: dest} + }) + + endLabel := st.FreshLabel("min_end") + st.Push(ir.JmpIfTrue{Cond: lt, Target: endLabel}) + st.Push(ir.UnaryOp{Op: ir.OpIntCopy{}, Arg: rightReg, Dest: minReg}) + st.Push(ir.LabelMarker{Label: endLabel}) + + return minReg +} + +// The conditional jumps take a bool, so a quantity has to be projected onto one +// first — which is what stops a monetary amount from being used as a condition by +// accident (ir.Typecheck rejects it). +func (st *state) jmpIfAmountZero(amountReg ir.Reg, target ir.Label) { + isZero := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIsZero{}, Arg: amountReg, Dest: dest} + }) + st.Push(ir.JmpIfTrue{Cond: isZero, Target: target}) +} + +// capReg is the register containing the current cap (or nil if context is uncapped) +// returns (when there's no err) the register where we store the pulled amount of this source +func (st *state) compileSource( + capReg *ir.Reg, + src parser.Source, +) (ir.Reg, CompilerError) { + switch src := src.(type) { + case *parser.SourceAccount: + accReg, err := st.compileExpr(src.ValueExpr) + if err != nil { + return 0, err + } + + colorReg, err := st.compileColor(src.Color) + if err != nil { + return 0, err + } + + overdraftReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{ + Value: *big.NewInt(0), + Dest: dest, + } + }) + + return st.pullFromAccount(accReg, capReg, &overdraftReg, colorReg), nil + + case *parser.SourceOverdraft: + if src.Bounded == nil && capReg == nil { + return 0, InvalidUncappedSource{ + Range: src.GetRange(), + } + } + + accReg, err := st.compileExpr(src.Address) + if err != nil { + return 0, err + } + + colorReg, err := st.compileColor(src.Color) + if err != nil { + return 0, err + } + + var overdraftReg *ir.Reg + if src.Bounded != nil { + amtReg, err := st.compileCapAmount(*src.Bounded) + if err != nil { + return 0, err + } + overdraftReg = &amtReg + } + + return st.pullFromAccount(accReg, capReg, overdraftReg, colorReg), nil + + case *parser.SourceCapped: + clauseCapIntReg, err := st.compileCapAmount(src.Cap) + if err != nil { + return 0, err + } + + var innerCapReg ir.Reg + if capReg == nil { + innerCapReg = clauseCapIntReg + } else { + innerCapReg = st.minInt(clauseCapIntReg, *capReg) + } + + return st.compileSource(&innerCapReg, src.From) + + case *parser.SourceInorder: + if capReg == nil { + inorderTotalReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{ + Value: *big.NewInt(0), + Dest: dest, + } + }) + for _, subSrc := range src.Sources { + innerPulledAmtReg, err := st.compileSource(nil, subSrc) + if err != nil { + return 0, err + } + // inorderTotalReg += innerPulledAmtReg + st.Push(ir.BinaryOp{ + Op: ir.OpAddInt{}, + Dest: inorderTotalReg, + Left: inorderTotalReg, + Right: innerPulledAmtReg, + }) + } + return inorderTotalReg, nil + } + + inorderTotalReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadInt{ + Value: *big.NewInt(0), + Dest: dest, + } + }) + + endLabel := st.FreshLabel("inorder_end") + inorderCap := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{ + Op: ir.OpIntCopy{}, + Arg: *capReg, + Dest: dest, + } + }) + + for idx, subSrc := range src.Sources { + innerPulledAmtReg, err := st.compileSource(&inorderCap, subSrc) + if err != nil { + return 0, err + } + + // inorderTotalReg += innerPulledAmtReg + st.Push(ir.BinaryOp{ + Op: ir.OpAddInt{}, + Dest: inorderTotalReg, + Left: inorderTotalReg, + Right: innerPulledAmtReg, + }) + + isLast := idx == len(src.Sources)-1 + if !isLast { + // inorderCap -= innerPulledAmtReg + st.Push(ir.BinaryOp{ + Op: ir.OpSubInt{}, + Dest: inorderCap, + Left: inorderCap, + Right: innerPulledAmtReg, + }) + st.jmpIfAmountZero(inorderCap, endLabel) + } + } + st.Push(ir.LabelMarker{ + Label: endLabel, + }) + return inorderTotalReg, nil + + case *parser.SourceOneof: + if err := st.checkFeatureFlag(src.GetRange(), flags.ExperimentalOneofFeatureFlag); err != nil { + return 0, err + } + + if capReg == nil || len(src.Sources) == 1 { + return st.compileSource(capReg, src.Sources[0]) + } + + endLabel := st.FreshLabel("oneof_end") + + st.Push(ir.MarkPush{}) + + // allocated at first use, not up front, to keep registers numbered in + // emission order (see ir.Builder.PushWithDest) + var resultReg ir.Reg + + for index, subSrc := range src.Sources { + subPulledAmtReg, err := st.compileSource(capReg, subSrc) + if err != nil { + return 0, err + } + + if index == 0 { + resultReg = st.FreshReg() + } + + st.Push(ir.UnaryOp{ + Op: ir.OpIntCopy{}, + Arg: subPulledAmtReg, + Dest: resultReg, + }) + + isLast := index == len(src.Sources)-1 + if !isLast { + // PRE: bounded capReg + // $missing_amt = $cap - $pulled_amt + missingAmt := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{ + Op: ir.OpSubInt{}, + Left: *capReg, + Right: subPulledAmtReg, + Dest: dest, + } + }) + + st.jmpIfAmountZero(missingAmt, endLabel) + // this branch fell short: undo it and reopen for the next one. There + // is no rewind-without-closing, so a retry is a close plus a push — + // and after the rollback the new mark is identical to the closed one. + st.Push(ir.MarkEnd{Rewind: true}) + st.Push(ir.MarkPush{}) + } + } + + st.Push(ir.LabelMarker{Label: endLabel}) + // every path into endLabel — the jumps from a branch that covered the cap, + // and the fallthrough from the last branch — has exactly one region open, so + // a single commit here closes it once on all of them. Keeping it + // unconditional at the join is what keeps mark depth a function of position. + st.Push(ir.MarkEnd{Rewind: false}) + + return resultReg, nil + + case *parser.SourceAllotment: + // an allotment source splits the cap among sub-sources, so it needs one + if capReg == nil { + return 0, InvalidUncappedSource{Range: src.GetRange()} + } + allotments := make([]parser.AllotmentValue, len(src.Items)) + for i, item := range src.Items { + allotments[i] = item.Allotment + } + shares, err := st.compileAllot(*capReg, allotments) + if err != nil { + return 0, err + } + // pull exactly its share from each sub-source (tryTakingExact) + for i, item := range src.Items { + if _, err := st.compileSourceWithRequiredAmount(shares[i], item.From); err != nil { + return 0, err + } + } + return *capReg, nil + + case *parser.SourceWithScaling: + if err := st.checkFeatureFlag(src.GetRange(), flags.AssetScaling); err != nil { + return 0, err + } + return 0, FeatureNotImplemented{Range: src.GetRange(), Feature: "scaling"} + + default: + return utils.NonExhaustiveMatchPanic[ir.Reg](src), nil + } +} + +func (st *state) compileSourceWithRequiredAmount( + capReg ir.Reg, + src parser.Source, +) (ir.Reg, CompilerError) { + got, err := st.compileSource(&capReg, src) + if err != nil { + return 0, err + } + st.Push(ir.CheckEnoughFunds{ + Got: got, + Needed: capReg, + }) + return got, nil +} + +func (st *state) compileDestination( + pulledAmtReg ir.Reg, + currentCap ir.Reg, + dest parser.Destination, +) CompilerError { + switch dest := dest.(type) { + case *parser.DestinationAllotment: + allotments := make([]parser.AllotmentValue, len(dest.Items)) + for i, item := range dest.Items { + allotments[i] = item.Allotment + } + // split the amount routed to this destination across the portions + shares, err := st.compileAllot(currentCap, allotments) + if err != nil { + return err + } + // send each computed share to its target (capped by that exact amount) + for i, item := range dest.Items { + if err := st.compileKeptOrDestination(item.To, pulledAmtReg, shares[i]); err != nil { + return err + } + } + return nil + + case *parser.DestinationOneof: + if err := st.checkFeatureFlag(dest.GetRange(), flags.ExperimentalOneofFeatureFlag); err != nil { + return err + } + + endLabel := st.FreshLabel("oneof_dest_end") + + clauseLabels := make([]ir.Label, len(dest.Clauses)) + for i, clause := range dest.Clauses { + clauseLabels[i] = st.FreshLabel("oneof_dest_clause") + + capAmtReg, err := st.compileCapAmount(clause.Cap) + if err != nil { + return err + } + minReg := st.minInt(currentCap, capAmtReg) + diff := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpSubInt{}, Left: currentCap, Right: minReg, Dest: dest} + }) + st.jmpIfAmountZero(diff, clauseLabels[i]) + } + + if err := st.compileKeptOrDestination(dest.Remaining, pulledAmtReg, currentCap); err != nil { + return err + } + st.Push(ir.Jmp{Target: endLabel}) + + for i, clause := range dest.Clauses { + st.Push(ir.LabelMarker{Label: clauseLabels[i]}) + if err := st.compileKeptOrDestination(clause.To, pulledAmtReg, currentCap); err != nil { + return err + } + st.Push(ir.Jmp{Target: endLabel}) + } + + st.Push(ir.LabelMarker{Label: endLabel}) + return nil + + case *parser.DestinationAccount: + accReg, err := st.compileExpr(dest.ValueExpr) + if err != nil { + return err + } + + var cap *ir.Reg + if pulledAmtReg != currentCap { + cap = ¤tCap + } + st.Push(ir.SendToAccount{ + Account: &accReg, + Cap: cap, + }) + + case *parser.DestinationInorder: + remaining := st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIntCopy{}, Arg: currentCap, Dest: dest} + }) + for _, clause := range dest.Clauses { + capAmtReg, err := st.compileCapAmount(clause.Cap) + if err != nil { + return err + } + amtReg := st.minInt(remaining, capAmtReg) + if err := st.compileKeptOrDestination(clause.To, pulledAmtReg, amtReg); err != nil { + return err + } + st.Push(ir.BinaryOp{Op: ir.OpSubInt{}, Dest: remaining, Left: remaining, Right: amtReg}) + } + + return st.compileKeptOrDestination(dest.Remaining, pulledAmtReg, remaining) + + default: + utils.NonExhaustiveMatchPanic[any](dest) + } + + return nil +} + +func (st *state) compileKeptOrDestination( + keptOrDest parser.KeptOrDestination, + pulledAmtReg ir.Reg, + currentCap ir.Reg, +) CompilerError { + switch keptOrDest := keptOrDest.(type) { + case *parser.DestinationTo: + return st.compileDestination(pulledAmtReg, currentCap, keptOrDest.Destination) + + case *parser.DestinationKept: + var cap *ir.Reg + if pulledAmtReg != currentCap { + cap = ¤tCap + } + st.Push(ir.SendToAccount{ + Account: nil, + Cap: cap, + }) + return nil + + default: + utils.NonExhaustiveMatchPanic[any](keptOrDest) + } + + return nil +} + +func (st *state) compileSentValue( + sentValue parser.SentValue, + source parser.Source, +) (ir.Reg, CompilerError) { + switch sentValue := sentValue.(type) { + case *parser.SentValueLiteral: + mon, err := st.compileMonetaryExpr(sentValue.Monetary) + if err != nil { + return 0, err + } + st.Push(ir.SetCurrentAsset{ + Asset: mon.Asset, + }) + st.currentAssetReg = &mon.Asset + + return st.compileSourceWithRequiredAmount(mon.Amount, source) + + case *parser.SentValueAll: + assetReg, err := st.compileExpr(sentValue.Asset) + if err != nil { + return 0, err + } + st.Push(ir.SetCurrentAsset{ + Asset: assetReg, + }) + st.currentAssetReg = &assetReg + return st.compileSource(nil, source) + + default: + return utils.NonExhaustiveMatchPanic[ir.Reg](sentValue), nil + } + +} + +func (st *state) compileStatements(stmt parser.Statement) CompilerError { + switch stmt := stmt.(type) { + case *parser.SendStatement: + pulledAmtReg, err := st.compileSentValue(stmt.SentValue, stmt.Source) + if err != nil { + return err + } + + err = st.compileDestination(pulledAmtReg, pulledAmtReg, stmt.Destination) + if err != nil { + return err + } + + return nil + + case *parser.SaveStatement: + var assetReg ir.Reg + var amountReg *ir.Reg + switch sv := stmt.SentValue.(type) { + case *parser.SentValueLiteral: + mon, err := st.compileMonetaryExpr(sv.Monetary) + if err != nil { + return err + } + assetReg = mon.Asset + amountReg = &mon.Amount + case *parser.SentValueAll: + r, err := st.compileExpr(sv.Asset) + if err != nil { + return err + } + assetReg = r + default: + utils.NonExhaustiveMatchPanic[any](stmt.SentValue) + } + + accReg, err := st.compileExpr(stmt.Account) + if err != nil { + return err + } + st.Push(ir.Save{Account: accReg, Asset: assetReg, Amount: amountReg}) + return nil + case *parser.FnCall: + switch stmt.Caller.Name { + case builtins.SetTxMeta: + key, err := st.compileExpr(stmt.Args[0]) + if err != nil { + return err + } + value, err := st.compileMetaValue(stmt.Args[1]) + if err != nil { + return err + } + st.Push(ir.SetTxMeta{Key: key, Value: value}) + return nil + + case builtins.SetAccountMeta: + account, err := st.compileExpr(stmt.Args[0]) + if err != nil { + return err + } + key, err := st.compileExpr(stmt.Args[1]) + if err != nil { + return err + } + value, err := st.compileMetaValue(stmt.Args[2]) + if err != nil { + return err + } + st.Push(ir.SetAccountMeta{Account: account, Key: key, Value: value}) + return nil + + default: + return utils.NonExhaustiveMatchPanic[CompilerError](stmt.Caller.Name) + } + + default: + return utils.NonExhaustiveMatchPanic[CompilerError](stmt) + } +} + +// compileMetaValue compiles a value into a string register (metadata is stored +// stringified). Strings/accounts/assets already live in string registers; +// numbers go through int_to_string. +func (st *state) compileMetaValue(expr parser.ValueExpr) (ir.Reg, CompilerError) { + if st.exprTypes[expr] == typecheck.TypeMonetary { + mon, err := st.compileMonetaryExpr(expr) + if err != nil { + return 0, err + } + return st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{ + Op: ir.OpMonetaryToString{}, + Left: mon.Asset, + Right: mon.Amount, + Dest: dest, + } + }), nil + } + + r, err := st.compileExpr(expr) + if err != nil { + return 0, err + } + + switch st.exprTypes[expr] { + case typecheck.TypeString, typecheck.TypeAccount, typecheck.TypeAsset: + return r, nil + case typecheck.TypeNumber: + return st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpIntToString{}, Arg: r, Dest: dest} + }), nil + case typecheck.TypePortion: + return st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.UnaryOp{Op: ir.OpPortionToString{}, Arg: r, Dest: dest} + }), nil + default: + panic("TODO meta value of type " + st.exprTypes[expr]) + } +} + +func compileProgramToIR(program parser.Program, featureFlags map[string]struct{}) (compiledProgramIR, CompilerError) { + tc := typecheck.Check(program) + if len(tc.Errors) > 0 { + return compiledProgramIR{}, TypeError{Range: tc.Errors[0].Range, Kind: tc.Errors[0].Kind} + } + + flagSet := maps.Clone(featureFlags) + if flagSet == nil { + flagSet = make(map[string]struct{}, len(program.Flags)) + } + for _, flag := range program.Flags { + if !slices.Contains(flags.AllFlags, flag.String) { + return compiledProgramIR{}, InvalidFeature{Range: flag.Range, Feature: flag.String} + } + flagSet[flag.String] = struct{}{} + } + + st := state{vars: map[string]value{}, exprTypes: tc.ExprTypes, featureFlags: flagSet} + + // loaded once, up front, so that it dominates every pullFromAccount branch + // regardless of the jumps those branches sit between + st.worldReg = st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadStr{Value: worldAccount, Dest: dest} + }) + + if program.Vars != nil { + for _, decl := range program.Vars.Declarations { + if err := st.compileVarDeclaration(decl); err != nil { + return compiledProgramIR{}, err + } + } + } + + for _, stmt := range program.Statements { + if err := st.compileStatements(stmt); err != nil { + return compiledProgramIR{}, err + } + } + + return compiledProgramIR{ + instructions: st.Instrs(), + varsEncoder: VarsEncoder{ + decls: st.varDecls, + nStr: st.nextStrVar, + nInt: st.nextIntVar, + }, + }, nil +} + +func (st *state) compileVarDeclaration(decl parser.VarDeclaration) CompilerError { + if decl.Origin == nil { + st.compileExternalVar(decl) + return nil + } + if decl.Type.Name == typecheck.TypeMonetary { + if fnCall, ok := (*decl.Origin).(*parser.FnCall); ok { + if fnCall.Caller.Name == builtins.Meta { + return st.compileMetaVar(decl, fnCall) + } + mon, err := st.compileMonetaryFnCall(fnCall, true) + if err != nil { + return err + } + st.vars[decl.Name.Name] = monValue(mon) + return nil + } + mon, err := st.compileMonetaryExpr(*decl.Origin) + if err != nil { + return err + } + st.vars[decl.Name.Name] = monValue(mon) + return nil + } + + var r ir.Reg + var err CompilerError + if fnCall, ok := (*decl.Origin).(*parser.FnCall); ok { + // meta() is only supported as a variable origin, statically dispatched on + // the declared type; elsewhere compileFnCall reports InvalidMetaPosition. + if fnCall.Caller.Name == builtins.Meta { + return st.compileMetaVar(decl, fnCall) + } + // a call that is the whole origin expression isn't a mid-script call + r, err = st.compileFnCall(fnCall, true) + } else { + r, err = st.compileExpr(*decl.Origin) + } + if err != nil { + return err + } + st.vars[decl.Name.Name] = scalarValue(r) + return nil +} + +func (st *state) compileMetaVar(decl parser.VarDeclaration, fnCall *parser.FnCall) CompilerError { + account, err := st.compileExpr(fnCall.Args[0]) + if err != nil { + return err + } + key, err := st.compileExpr(fnCall.Args[1]) + if err != nil { + return err + } + + // monetary is the one meta type whose single store read yields two values, so + // it has its own two-destination instruction rather than a MetaType. + if decl.Type.Name == typecheck.TypeMonetary { + destAsset := st.FreshReg() + destAmount := st.FreshReg() + st.Push(ir.MetaMonetary{ + DestAsset: destAsset, + DestAmount: destAmount, + Account: account, + Key: key, + }) + st.vars[decl.Name.Name] = monValue(monetaryValue{Asset: destAsset, Amount: destAmount}) + return nil + } + + var typ ir.MetaType + switch decl.Type.Name { + case typecheck.TypeString, typecheck.TypeAccount, typecheck.TypeAsset: + typ = ir.MetaStr{} + case typecheck.TypeNumber: + typ = ir.MetaInt{} + case typecheck.TypePortion: + typ = ir.MetaPortion{} + default: + panic("unexpected meta var type: " + decl.Type.Name) + } + + st.vars[decl.Name.Name] = scalarValue(st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.MetaVar{Dest: dest, Account: account, Key: key, Typ: typ} + })) + return nil +} + +// TODO review AI blob +func (st *state) compileExternalVar(decl parser.VarDeclaration) { + name := decl.Name.Name + st.varDecls = append(st.varDecls, varDecl{name: name, typ: decl.Type.Name}) + + switch decl.Type.Name { + case typecheck.TypeNumber: + st.vars[name] = scalarValue(st.loadIntVar()) + + case typecheck.TypeString, typecheck.TypeAsset, typecheck.TypeAccount: + st.vars[name] = scalarValue(st.loadStrVar()) + + case typecheck.TypePortion: + num := st.loadIntVar() + den := st.loadIntVar() + st.vars[name] = scalarValue(st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.BinaryOp{Op: ir.OpMakePortion{}, Left: num, Right: den, Dest: dest} + })) + + case typecheck.TypeMonetary: + // the vars payload already carries a monetary as two scalars, so the pair + // is the value — nothing to assemble + asset := st.loadStrVar() + amount := st.loadIntVar() + st.vars[name] = monValue(monetaryValue{Asset: asset, Amount: amount}) + + default: + panic("unexpected var type: " + decl.Type.Name) + } +} + +func (st *state) loadIntVar() ir.Reg { + index := uint16(st.nextIntVar) + st.nextIntVar++ + return st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadVar{Dest: dest, Typ: ir.VarInt{}, Index: index} + }) +} + +func (st *state) loadStrVar() ir.Reg { + index := uint16(st.nextStrVar) + st.nextStrVar++ + return st.PushWithDest(func(dest ir.Reg) ir.Instr { + return ir.LoadVar{Dest: dest, Typ: ir.VarStr{}, Index: index} + }) +} diff --git a/internal/compiler/compiler_error.go b/internal/compiler/compiler_error.go new file mode 100644 index 00000000..5cca733e --- /dev/null +++ b/internal/compiler/compiler_error.go @@ -0,0 +1,118 @@ +package compiler + +import ( + "fmt" + + "github.com/formancehq/numscript/internal/flags" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" +) + +type ( + CompilerError interface { + parser.Ranged + compileError() + } + + UnboundVar struct { + parser.Range + Var string + } + + TypeError struct { + parser.Range + Kind typecheck.ErrorKind + } + + InvalidUncappedSource struct { + parser.Range + } + + DuplicateRemaining struct { + parser.Range + } + + // InvalidMetaPosition is reported when meta() appears anywhere other than as + // a top-level variable origin (the only place it's supported). + InvalidMetaPosition struct { + parser.Range + } + + // CannotCastToString is reported for an interpolation part whose type has no + // string form (monetary, asset, portion). + CannotCastToString struct { + parser.Range + Type typecheck.Type + } + + // FeatureNotImplemented is returned (never panicked) when the compiler meets a + // construct it does not support yet — e.g. colors or scoped accounts — so the + // host gets an error instead of a crash. + FeatureNotImplemented struct { + parser.Range + Feature string + } + + // ExperimentalFeature is reported when the script uses a construct gated + // behind a feature flag that wasn't enabled. Mirrors the interpreter's + // interpreter.ExperimentalFeature. + ExperimentalFeature struct { + parser.Range + FlagName flags.FeatureFlag + } + + // InvalidFeature is reported when a #![feature(..)] declaration names a flag + // that doesn't exist. + InvalidFeature struct { + parser.Range + Feature string + } +) + +func (UnboundVar) compileError() {} +func (TypeError) compileError() {} +func (InvalidUncappedSource) compileError() {} +func (DuplicateRemaining) compileError() {} +func (InvalidMetaPosition) compileError() {} +func (CannotCastToString) compileError() {} +func (FeatureNotImplemented) compileError() {} +func (ExperimentalFeature) compileError() {} +func (InvalidFeature) compileError() {} + +func (e FeatureNotImplemented) Error() string { + return "internal error: feature not implemented: " + e.Feature +} +func (e UnboundVar) Error() string { + return fmt.Sprintf("the variable '$%s' was not declared", e.Var) +} +func (InvalidUncappedSource) Error() string { + return "cannot take all balance of an unbounded source" +} +func (DuplicateRemaining) Error() string { + return "a 'remaining' clause should be the last in an allotment expression" +} +func (e TypeError) Error() string { return e.Kind.Message() } +func (InvalidMetaPosition) Error() string { + return "meta() is only allowed as a variable origin" +} +func (e CannotCastToString) Error() string { + return "cannot cast a value of type " + string(e.Type) + " to string" +} +func (e ExperimentalFeature) Error() string { + return fmt.Sprintf("this feature is experimental. You need the '%s' feature flag to enable it", e.FlagName) +} +func (e InvalidFeature) Error() string { + return fmt.Sprintf("Invalid feature: %s", e.Feature) +} + +var ( + _ CompilerError = (*UnboundVar)(nil) + _ CompilerError = (*TypeError)(nil) + _ CompilerError = (*InvalidUncappedSource)(nil) + _ CompilerError = (*DuplicateRemaining)(nil) + _ CompilerError = (*InvalidMetaPosition)(nil) + _ CompilerError = (*CannotCastToString)(nil) + _ CompilerError = (*FeatureNotImplemented)(nil) + _ CompilerError = (*ExperimentalFeature)(nil) + _ CompilerError = (*InvalidFeature)(nil) +) diff --git a/internal/compiler/compiler_example_test.go b/internal/compiler/compiler_example_test.go new file mode 100644 index 00000000..b2910d85 --- /dev/null +++ b/internal/compiler/compiler_example_test.go @@ -0,0 +1,85 @@ +package compiler_test + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript" + "github.com/stretchr/testify/require" +) + +func TestCompilerExample(t *testing.T) { + script := ` + vars { + account $acc + } + + send [USD/2 10] ( + source = $acc + destination = @dest + ) + ` + + varsEncoder, compiledProgram, compilationErr := numscript.Compile(script) + require.NoError(t, compilationErr) // e.g. parsing errors or type errors or any other kind of compile-time errors + + { + // The compiledProgram represents the compiled version of the program. + // We can serialize it into a []byte sequence and decode it back to the same data structure. + // the serialised []byte format is meant to be used to send it over the wire + bytecode := compiledProgram.Encode() // <- cast to []byte + + decodedCompiledProgram, decodingErr := numscript.DecodeCompiledProgram(bytecode) // <- decode it back + require.NoError(t, decodingErr) + require.Equal(t, decodedCompiledProgram, compiledProgram) + } + + // the vars encoder must be stored by the leader, so that it can encode the vars payload + // in a way that can be consumed by the vm + vars, err := varsEncoder.Encode(map[string]string{ + "acc": "src_account", + }) + require.NoError(t, err) + + { + // just like the compiledProgram. the Vars can be serialised and deserialised into/from []byte + serialisedVars := vars.Encode() // <- []byte to be sent over the wire from leader to nodes + + decodedVars, decodingErr := numscript.DecodeVars(serialisedVars) // <- turning []byte into Vars + require.NoError(t, decodingErr) + require.Equal(t, decodedVars, vars) + } + + // We can initialise the vm by passing the numscript.CompiledProgram value. + // Not only it's valid to re-use the same instance of the VM from many script runs, + // it's actually best to keep that in memory instead of the keeping the program and re-creating the vm each time + // this way we can avoid allocating/deallocating the registers and vm state each time + vm := numscript.NewVm(compiledProgram) + + // mock store (repr'd as map) + store := testStore{ + "src_account": 100, + } + + result, execErr := numscript.ExecVm(context.Background(), vm, &vars, store) + require.NoError(t, execErr) // e.g. missing funds, or any other runtime error + require.Equal(t, []numscript.Posting{ + { + Source: "src_account", + Destination: "dest", + Asset: "USD/2", + Amount: big.NewInt(10), + }, + }, result.Postings) +} + +type testStore map[string]int64 + +func (s testStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { + return big.NewInt(s[account]), nil +} + +func (testStore) GetMetadata(ctx context.Context, account, key string) (string, bool, error) { + return "", false, nil +} diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go new file mode 100644 index 00000000..5714c4fc --- /dev/null +++ b/internal/compiler/compiler_test.go @@ -0,0 +1,796 @@ +package compiler + +import ( + "testing" + + "github.com/formancehq/numscript/internal/ir" + "github.com/formancehq/numscript/internal/parser" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" +) + +func getCompiledOutput(t *testing.T, source string) string { + t.Helper() + program := parser.Parse(source) + require.Empty(t, program.Errors) + compiled, err := compileProgramToIR(program.Value, nil) + require.Nil(t, err) + + out := "\n" + ir.Dump(compiled.instructions) + + // every snapshot below doubles as a round-trip test of the textual format + instrs, errs := ir.Parse(out) + require.Empty(t, errs, "the dump does not parse back") + require.Equal(t, out, "\n"+ir.Dump(instrs), "the dump does not round-trip") + + return out +} + +func TestSimpleProgram(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + $r3 = "src" + $r4 = 0 + $r5 = str_eq($r3, $r0) + jmp_if_false($r5, #not_world_0) + $r6 = pull_account(account: $r3, cap: $r2) + jmp(#pull_end_1) +#not_world_0 + $r6 = pull_account(account: $r3, cap: $r2, overdraft: $r4) +#pull_end_1 + check_enough_funds($r6, $r2) + $r7 = "dest" + send_to_account(account: $r7) +`)) +} + +func TestIntAddition(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 4 + 6] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 4 + $r3 = 6 + $r4 = $r2 + $r3 + set_current_asset($r1) + $r5 = "src" + $r6 = 0 + $r7 = str_eq($r5, $r0) + jmp_if_false($r7, #not_world_0) + $r8 = pull_account(account: $r5, cap: $r4) + jmp(#pull_end_1) +#not_world_0 + $r8 = pull_account(account: $r5, cap: $r4, overdraft: $r6) +#pull_end_1 + check_enough_funds($r8, $r4) + $r9 = "dest" + send_to_account(account: $r9) +`)) +} + +func TestIntSubtraction(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 16 - 6] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 16 + $r3 = 6 + $r4 = $r2 - $r3 + set_current_asset($r1) + $r5 = "src" + $r6 = 0 + $r7 = str_eq($r5, $r0) + jmp_if_false($r7, #not_world_0) + $r8 = pull_account(account: $r5, cap: $r4) + jmp(#pull_end_1) +#not_world_0 + $r8 = pull_account(account: $r5, cap: $r4, overdraft: $r6) +#pull_end_1 + check_enough_funds($r8, $r4) + $r9 = "dest" + send_to_account(account: $r9) +`)) +} + +func TestMonetaryAddition(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $a = [USD/2 3] + monetary $b = [USD/2 7] + } + send $a + $b ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 3 + $r3 = "USD/2" + $r4 = 7 + assert_same_asset($r1, $r3) + $r5 = $r2 + $r4 + set_current_asset($r1) + $r6 = "src" + $r7 = 0 + $r8 = str_eq($r6, $r0) + jmp_if_false($r8, #not_world_0) + $r9 = pull_account(account: $r6, cap: $r5) + jmp(#pull_end_1) +#not_world_0 + $r9 = pull_account(account: $r6, cap: $r5, overdraft: $r7) +#pull_end_1 + check_enough_funds($r9, $r5) + $r10 = "dest" + send_to_account(account: $r10) +`)) +} + +func TestMonetarySubtraction(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $a = [USD/2 30] + monetary $b = [USD/2 20] + } + send $a - $b ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 30 + $r3 = "USD/2" + $r4 = 20 + assert_same_asset($r1, $r3) + $r5 = $r2 - $r4 + set_current_asset($r1) + $r6 = "src" + $r7 = 0 + $r8 = str_eq($r6, $r0) + jmp_if_false($r8, #not_world_0) + $r9 = pull_account(account: $r6, cap: $r5) + jmp(#pull_end_1) +#not_world_0 + $r9 = pull_account(account: $r6, cap: $r5, overdraft: $r7) +#pull_end_1 + check_enough_funds($r9, $r5) + $r10 = "dest" + send_to_account(account: $r10) +`)) +} + +func TestGetAmount(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-get-amount-function")] + vars { + monetary $m = [USD/2 42] + number $n = get_amount($m) + } + send [USD/2 $n] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 42 + $r3 = "USD/2" + set_current_asset($r3) + $r4 = "src" + $r5 = 0 + $r6 = str_eq($r4, $r0) + jmp_if_false($r6, #not_world_0) + $r7 = pull_account(account: $r4, cap: $r2) + jmp(#pull_end_1) +#not_world_0 + $r7 = pull_account(account: $r4, cap: $r2, overdraft: $r5) +#pull_end_1 + check_enough_funds($r7, $r2) + $r8 = "dest" + send_to_account(account: $r8) +`)) +} + +func TestGetAsset(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-get-asset-function")] + vars { + monetary $m = [USD/2 42] + asset $a = get_asset($m) + } + send [$a 10] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 42 + $r3 = 10 + set_current_asset($r1) + $r4 = "src" + $r5 = 0 + $r6 = str_eq($r4, $r0) + jmp_if_false($r6, #not_world_0) + $r7 = pull_account(account: $r4, cap: $r3) + jmp(#pull_end_1) +#not_world_0 + $r7 = pull_account(account: $r4, cap: $r3, overdraft: $r5) +#pull_end_1 + check_enough_funds($r7, $r3) + $r8 = "dest" + send_to_account(account: $r8) +`)) +} + +func TestPrefixMinusMonetary(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $neg_mon = [USD/2 -10] + monetary $pos_mon = -$neg_mon + } + send $pos_mon ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + $r3 = neg_int($r2) + $r4 = neg_int($r3) + set_current_asset($r1) + $r5 = "src" + $r6 = 0 + $r7 = str_eq($r5, $r0) + jmp_if_false($r7, #not_world_0) + $r8 = pull_account(account: $r5, cap: $r4) + jmp(#pull_end_1) +#not_world_0 + $r8 = pull_account(account: $r5, cap: $r4, overdraft: $r6) +#pull_end_1 + check_enough_funds($r8, $r4) + $r9 = "dest" + send_to_account(account: $r9) +`)) +} + +func TestBalance(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $bal = balance(@src, USD/2) + } + send $bal ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "src" + $r2 = "USD/2" + $r3 = balance($r1, $r2) + assert_non_negative_balance($r3, $r1) + set_current_asset($r2) + $r4 = "src" + $r5 = 0 + $r6 = str_eq($r4, $r0) + jmp_if_false($r6, #not_world_0) + $r7 = pull_account(account: $r4, cap: $r3) + jmp(#pull_end_1) +#not_world_0 + $r7 = pull_account(account: $r4, cap: $r3, overdraft: $r5) +#pull_end_1 + check_enough_funds($r7, $r3) + $r8 = "dest" + send_to_account(account: $r8) +`)) +} + +func TestAccountInterpolation(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-account-interpolation")] + vars { + string $id = "alice" + } + send [USD/2 10] ( + source = @world + destination = @users:$id:wallet + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "alice" + $r2 = "USD/2" + $r3 = 10 + set_current_asset($r2) + $r4 = "world" + $r5 = 0 + $r6 = str_eq($r4, $r0) + jmp_if_false($r6, #not_world_0) + $r7 = pull_account(account: $r4, cap: $r3) + jmp(#pull_end_1) +#not_world_0 + $r7 = pull_account(account: $r4, cap: $r3, overdraft: $r5) +#pull_end_1 + check_enough_funds($r7, $r3) + $r8 = "users" + $r9 = ":" + $r10 = ":" + $r11 = "wallet" + $r12 = add_string($r8, $r9) + $r13 = add_string($r12, $r1) + $r14 = add_string($r13, $r10) + $r15 = add_string($r14, $r11) + assert_valid_account($r15) + send_to_account(account: $r15) +`)) +} + +func TestAccountInterpolationInt(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-account-interpolation")] + vars { + number $n = 42 + } + send [USD/2 10] ( + source = @world + destination = @account:$n + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = 42 + $r2 = "USD/2" + $r3 = 10 + set_current_asset($r2) + $r4 = "world" + $r5 = 0 + $r6 = str_eq($r4, $r0) + jmp_if_false($r6, #not_world_0) + $r7 = pull_account(account: $r4, cap: $r3) + jmp(#pull_end_1) +#not_world_0 + $r7 = pull_account(account: $r4, cap: $r3, overdraft: $r5) +#pull_end_1 + check_enough_funds($r7, $r3) + $r8 = "account" + $r9 = ":" + $r10 = int_to_string($r1) + $r11 = add_string($r8, $r9) + $r12 = add_string($r11, $r10) + assert_valid_account($r12) + send_to_account(account: $r12) +`)) +} + +func TestInorder(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = { + @a + @b + @c + } + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + $r3 = 0 + $r4 = int_copy($r2) + $r5 = "a" + $r6 = 0 + $r7 = str_eq($r5, $r0) + jmp_if_false($r7, #not_world_1) + $r8 = pull_account(account: $r5, cap: $r4) + jmp(#pull_end_2) +#not_world_1 + $r8 = pull_account(account: $r5, cap: $r4, overdraft: $r6) +#pull_end_2 + $r3 += $r8 + $r4 -= $r8 + $r9 = is_zero($r4) + jmp_if_true($r9, #inorder_end_0) + $r10 = "b" + $r11 = 0 + $r12 = str_eq($r10, $r0) + jmp_if_false($r12, #not_world_3) + $r13 = pull_account(account: $r10, cap: $r4) + jmp(#pull_end_4) +#not_world_3 + $r13 = pull_account(account: $r10, cap: $r4, overdraft: $r11) +#pull_end_4 + $r3 += $r13 + $r4 -= $r13 + $r14 = is_zero($r4) + jmp_if_true($r14, #inorder_end_0) + $r15 = "c" + $r16 = 0 + $r17 = str_eq($r15, $r0) + jmp_if_false($r17, #not_world_5) + $r18 = pull_account(account: $r15, cap: $r4) + jmp(#pull_end_6) +#not_world_5 + $r18 = pull_account(account: $r15, cap: $r4, overdraft: $r16) +#pull_end_6 + $r3 += $r18 +#inorder_end_0 + check_enough_funds($r3, $r2) + $r19 = "dest" + send_to_account(account: $r19) +`)) +} + +func TestInorderWithCap(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + $r3 = 0 + $r4 = int_copy($r2) + $r5 = "a" + $r6 = 0 + $r7 = str_eq($r5, $r0) + jmp_if_false($r7, #not_world_1) + $r8 = pull_account(account: $r5, cap: $r4) + jmp(#pull_end_2) +#not_world_1 + $r8 = pull_account(account: $r5, cap: $r4, overdraft: $r6) +#pull_end_2 + $r3 += $r8 + $r4 -= $r8 + $r9 = is_zero($r4) + jmp_if_true($r9, #inorder_end_0) + $r10 = "USD/2" + $r11 = 5 + assert_same_asset($r10, $r1) + $r12 = int_copy($r11) + $r13 = lt_int($r11, $r4) + jmp_if_true($r13, #min_end_3) + $r12 = int_copy($r4) +#min_end_3 + $r14 = "b" + $r15 = 0 + $r16 = str_eq($r14, $r0) + jmp_if_false($r16, #not_world_4) + $r17 = pull_account(account: $r14, cap: $r12) + jmp(#pull_end_5) +#not_world_4 + $r17 = pull_account(account: $r14, cap: $r12, overdraft: $r15) +#pull_end_5 + $r3 += $r17 + $r4 -= $r17 + $r18 = is_zero($r4) + jmp_if_true($r18, #inorder_end_0) + $r19 = "c" + $r20 = 0 + $r21 = str_eq($r19, $r0) + jmp_if_false($r21, #not_world_6) + $r22 = pull_account(account: $r19, cap: $r4) + jmp(#pull_end_7) +#not_world_6 + $r22 = pull_account(account: $r19, cap: $r4, overdraft: $r20) +#pull_end_7 + $r3 += $r22 +#inorder_end_0 + check_enough_funds($r3, $r2) + $r23 = "dest" + send_to_account(account: $r23) +`)) +} + +func TestDestInorder(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = @world + destination = { + max [USD/2 4] to @d1 + remaining to @d2 + } + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + $r3 = "world" + $r4 = 0 + $r5 = str_eq($r3, $r0) + jmp_if_false($r5, #not_world_0) + $r6 = pull_account(account: $r3, cap: $r2) + jmp(#pull_end_1) +#not_world_0 + $r6 = pull_account(account: $r3, cap: $r2, overdraft: $r4) +#pull_end_1 + check_enough_funds($r6, $r2) + $r7 = int_copy($r6) + $r8 = "USD/2" + $r9 = 4 + assert_same_asset($r8, $r1) + $r10 = int_copy($r7) + $r11 = lt_int($r7, $r9) + jmp_if_true($r11, #min_end_2) + $r10 = int_copy($r9) +#min_end_2 + $r12 = "d1" + send_to_account(account: $r12, cap: $r10) + $r7 -= $r10 + $r13 = "d2" + send_to_account(account: $r13, cap: $r7) +`)) +} + +func TestSourceOneofSimple(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-oneof")] + send [USD/2 10] ( + source = oneof { + @a + @b + @c + } + destination = @dest + ) + `) + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + mark_push() + $r3 = "a" + $r4 = 0 + $r5 = str_eq($r3, $r0) + jmp_if_false($r5, #not_world_1) + $r6 = pull_account(account: $r3, cap: $r2) + jmp(#pull_end_2) +#not_world_1 + $r6 = pull_account(account: $r3, cap: $r2, overdraft: $r4) +#pull_end_2 + $r7 = int_copy($r6) + $r8 = $r2 - $r6 + $r9 = is_zero($r8) + jmp_if_true($r9, #oneof_end_0) + mark_rewind() + mark_push() + $r10 = "b" + $r11 = 0 + $r12 = str_eq($r10, $r0) + jmp_if_false($r12, #not_world_3) + $r13 = pull_account(account: $r10, cap: $r2) + jmp(#pull_end_4) +#not_world_3 + $r13 = pull_account(account: $r10, cap: $r2, overdraft: $r11) +#pull_end_4 + $r7 = int_copy($r13) + $r14 = $r2 - $r13 + $r15 = is_zero($r14) + jmp_if_true($r15, #oneof_end_0) + mark_rewind() + mark_push() + $r16 = "c" + $r17 = 0 + $r18 = str_eq($r16, $r0) + jmp_if_false($r18, #not_world_5) + $r19 = pull_account(account: $r16, cap: $r2) + jmp(#pull_end_6) +#not_world_5 + $r19 = pull_account(account: $r16, cap: $r2, overdraft: $r17) +#pull_end_6 + $r7 = int_copy($r19) +#oneof_end_0 + mark_commit() + check_enough_funds($r7, $r2) + $r20 = "dest" + send_to_account(account: $r20) +`)) +} + +func TestSourceOneofBounded(t *testing.T) { + + out := getCompiledOutput(t, ` + #![feature("experimental-oneof")] + send [USD/2 10] ( + source = oneof { + @a + @b + } + destination = @dest + ) + `) + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + mark_push() + $r3 = "a" + $r4 = 0 + $r5 = str_eq($r3, $r0) + jmp_if_false($r5, #not_world_1) + $r6 = pull_account(account: $r3, cap: $r2) + jmp(#pull_end_2) +#not_world_1 + $r6 = pull_account(account: $r3, cap: $r2, overdraft: $r4) +#pull_end_2 + $r7 = int_copy($r6) + $r8 = $r2 - $r6 + $r9 = is_zero($r8) + jmp_if_true($r9, #oneof_end_0) + mark_rewind() + mark_push() + $r10 = "b" + $r11 = 0 + $r12 = str_eq($r10, $r0) + jmp_if_false($r12, #not_world_3) + $r13 = pull_account(account: $r10, cap: $r2) + jmp(#pull_end_4) +#not_world_3 + $r13 = pull_account(account: $r10, cap: $r2, overdraft: $r11) +#pull_end_4 + $r7 = int_copy($r13) +#oneof_end_0 + mark_commit() + check_enough_funds($r7, $r2) + $r14 = "dest" + send_to_account(account: $r14) +`)) +} + +func TestDestOneof(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-oneof")] + send [USD/2 10] ( + source = @world + destination = oneof { + max [USD/2 4] to @a + remaining to @b + } + ) + `) + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "USD/2" + $r2 = 10 + set_current_asset($r1) + $r3 = "world" + $r4 = 0 + $r5 = str_eq($r3, $r0) + jmp_if_false($r5, #not_world_0) + $r6 = pull_account(account: $r3, cap: $r2) + jmp(#pull_end_1) +#not_world_0 + $r6 = pull_account(account: $r3, cap: $r2, overdraft: $r4) +#pull_end_1 + check_enough_funds($r6, $r2) + $r7 = "USD/2" + $r8 = 4 + assert_same_asset($r7, $r1) + $r9 = int_copy($r6) + $r10 = lt_int($r6, $r8) + jmp_if_true($r10, #min_end_4) + $r9 = int_copy($r8) +#min_end_4 + $r11 = $r6 - $r9 + $r12 = is_zero($r11) + jmp_if_true($r12, #oneof_dest_clause_3) + $r13 = "b" + send_to_account(account: $r13) + jmp(#oneof_dest_end_2) +#oneof_dest_clause_3 + $r14 = "a" + send_to_account(account: $r14) + jmp(#oneof_dest_end_2) +#oneof_dest_end_2 +`)) +} + +func TestColoredSource(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-asset-colors")] + send [COIN 10] ( + source = @src \ "RED" + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "COIN" + $r2 = 10 + set_current_asset($r1) + $r3 = "src" + $r4 = "RED" + assert_valid_color($r4) + $r5 = 0 + $r6 = str_eq($r3, $r0) + jmp_if_false($r6, #not_world_0) + $r7 = pull_account(account: $r3, cap: $r2, color: $r4) + jmp(#pull_end_1) +#not_world_0 + $r7 = pull_account(account: $r3, cap: $r2, overdraft: $r5, color: $r4) +#pull_end_1 + check_enough_funds($r7, $r2) + $r8 = "dest" + send_to_account(account: $r8) +`)) +} + +func TestColoredOverdraftSource(t *testing.T) { + out := getCompiledOutput(t, ` + #![feature("experimental-asset-colors")] + send [COIN 10] ( + source = @src \ "RED" allowing unbounded overdraft + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 = "world" + $r1 = "COIN" + $r2 = 10 + set_current_asset($r1) + $r3 = "src" + $r4 = "RED" + assert_valid_color($r4) + $r5 = pull_account(account: $r3, cap: $r2, color: $r4) + check_enough_funds($r5, $r2) + $r6 = "dest" + send_to_account(account: $r6) +`)) +} diff --git a/internal/compiler/e2e_test.go b/internal/compiler/e2e_test.go new file mode 100644 index 00000000..17c9e71f --- /dev/null +++ b/internal/compiler/e2e_test.go @@ -0,0 +1,1337 @@ +package compiler_test + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +// e2eStore is a minimal vm.Store for the end-to-end test. +type e2eStore struct { + balances map[runtime.PairKey]*big.Int + metadata map[string]map[string]string +} + +func (s e2eStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { + if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + return v, nil + } + return new(big.Int), nil +} + +func (s e2eStore) GetMetadata(ctx context.Context, account, key string) (string, bool, error) { + v, ok := s.metadata[account][key] + return v, ok, nil +} + +// TestE2E_CompileAssembleRun exercises the whole pipeline: source -> compiler +// (IR) -> assembler (vm.Program) -> VM execution -> postings. +func TestE2E_CompileAssembleRun(t *testing.T) { + src := ` + send [USD/2 10] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_Inorder exercises an inorder source { @a @b @c } end-to-end, including +// the early-exit jump: @a has 6, @b has 10, @c has 100; sending 10 pulls 6 from +// @a (cap -> 4), then 4 from @b (cap -> 0 -> jump past @c). @c is never touched. +func TestE2E_Inorder(t *testing.T) { + src := ` + send [USD/2 10] ( + source = { + @a + @b + @c + } + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(6), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(10), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(6)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(4)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_InorderWithCap exercises a capped (`max`) source inside an inorder +// end-to-end. @b holds 100 but is capped at 5, so the cap must bind: @a gives 3 +// (remaining 10->7), @b gives only 5 (not 7) -> remaining 2, @c gives 2. +func TestE2E_InorderWithCap(t *testing.T) { + src := ` + send [USD/2 10] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(3), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(3)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(5)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(2)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_InsufficientFunds checks the failure path: when the source can't cover +// the sent amount, the VM's CheckEnoughFunds must report a MissingFundsError. +func TestE2E_InsufficientFunds(t *testing.T) { + src := ` + send [USD/2 10] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + // src only has 4, but 10 is required. + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(4), + }} + + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, store) + require.IsType(t, vm.MissingFundsError{}, execErr) +} + +// TestE2E_DestinationInorder exercises a destination-inorder split end-to-end: +// 100 pulled from @world is distributed as `max [USD/2 30] to @x; remaining to +// @y`, so @x must get 30 and @y the remaining 70. +func TestE2E_DestinationInorder(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + max [USD/2 30] to @x + remaining to @y + } + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{}} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "world", Destination: "x", Asset: "USD/2", Amount: big.NewInt(30)}, + {Source: "world", Destination: "y", Asset: "USD/2", Amount: big.NewInt(70)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_DestinationKept exercises a `kept` clause: of 100 pulled from @world, +// 30 is kept (refunded, no posting) and the remaining 70 goes to @y. +func TestE2E_DestinationKept(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + max [USD/2 30] kept + remaining to @y + } + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{}} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + + // only the remaining 70 is posted; the kept 30 produces no posting + want := []runtime.Posting{ + {Source: "world", Destination: "y", Asset: "USD/2", Amount: big.NewInt(70)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_DestinationAllotment splits the pulled amount by portions. 100 from +// @world with { 1/2 to @a; remaining to @b } => a=50, b=50. +func TestE2E_DestinationAllotment(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/2 to @a + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(50)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(50)}, + }, postings) +} + +// TestE2E_DestinationAllotmentThirds exercises the floor-then-distribute-leftover +// rounding: 100 by thirds => 34, 33, 33 (the leftover unit goes to the earliest). +func TestE2E_DestinationAllotmentThirds(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/3 to @a + 1/3 to @b + remaining to @c + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(34)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(33)}, + {Source: "world", Destination: "c", Asset: "USD/2", Amount: big.NewInt(33)}, + }, postings) +} + +// TestE2E_SourceAllotment splits the requested amount across sub-sources, pulling +// each exactly. 100 with { 1/4 from @s1; remaining from @s2 } => 25 from s1, 75 +// from s2. +func TestE2E_SourceAllotment(t *testing.T) { + src := ` + send [USD/2 100] ( + source = { + 1/4 from @s1 + remaining from @s2 + } + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "s1", Asset: "USD/2", Color: ""}: big.NewInt(1000), + {Account: "s2", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "s1", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(25)}, + {Source: "s2", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(75)}, + }, postings) +} + +// TestE2E_SourceAllotmentThirds checks the rounding split on the source side too: +// 100 by thirds => 34, 33, 33. +func TestE2E_SourceAllotmentThirds(t *testing.T) { + src := ` + send [USD/2 100] ( + source = { + 1/3 from @a + 1/3 from @b + remaining from @c + } + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(1000), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(1000), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(34)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(33)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(33)}, + }, postings) +} + +// TestE2E_SourceAllotmentInsufficient: a sub-source must provide its exact share, +// else MissingFunds. s1 only has 10 but its 1/2 share of 100 is 50. +func TestE2E_SourceAllotmentInsufficient(t *testing.T) { + src := ` + send [USD/2 100] ( + source = { + 1/2 from @s1 + remaining from @s2 + } + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "s1", Asset: "USD/2", Color: ""}: big.NewInt(10), + {Account: "s2", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }} + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, store) + require.IsType(t, vm.MissingFundsError{}, execErr) +} + +// TestE2E_AllotmentOverSum: portions summing to > 1 must error (leftover < 0). +func TestE2E_AllotmentOverSum(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 2/3 to @a + 2/3 to @b + } + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.InvalidAllotmentSum{}, execErr) + allotErr := execErr.(vm.InvalidAllotmentSum) + require.Equal(t, "4/3", allotErr.ActualSum.String()) + require.EqualError(t, allotErr, "invalid allotment: portions must sum to 1, got 4/3") +} + +// TestE2E_AllotmentUnderSum: without a `remaining` clause the portions must sum +// to exactly 1, so 1/3 + 1/3 = 2/3 must error. +func TestE2E_AllotmentUnderSum(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/3 to @a + 1/3 to @b + } + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.InvalidAllotmentSum{}, execErr) +} + +// TestE2E_AllotmentExactNoRemaining: a no-remaining allotment summing to exactly +// 1 is valid. +func TestE2E_AllotmentExactNoRemaining(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/4 to @a + 3/4 to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(25)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(75)}, + }, postings) +} + +// TestE2E_AllotmentRemainingOnly: `{ remaining to @dest }` is 100% (leftover = 1), +// which must remain valid (a `< 1` check would wrongly reject it). +func TestE2E_AllotmentRemainingOnly(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + remaining to @dest + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) +} + +func TestE2E_IntAddition(t *testing.T) { + src := ` + send [USD/2 4 + 6] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_IntSubtraction(t *testing.T) { + src := ` + send [USD/2 16 - 6] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_MonetaryAddition(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 3] + monetary $b = [USD/2 7] + } + send $a + $b ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_MonetarySubtraction(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 30] + monetary $b = [USD/2 20] + } + send $a - $b ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_MonetarySubtractionAssetMismatch(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 30] + monetary $b = [EUR/2 20] + } + send $a - $b ( + source = @src + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + _, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_MonetaryAdditionAssetMismatch(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 3] + monetary $b = [EUR/2 7] + } + send $a + $b ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + _, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, store) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_GetAmount(t *testing.T) { + src := ` + #![feature("experimental-get-amount-function")] + vars { + monetary $m = [USD/2 42] + number $n = get_amount($m) + } + send [USD/2 $n] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(42)}, + }, res.Postings) +} + +func TestE2E_GetAsset(t *testing.T) { + src := ` + #![feature("experimental-get-asset-function")] + vars { + monetary $m = [USD/2 42] + asset $a = get_asset($m) + } + send [$a 10] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_PrefixMinusNumber(t *testing.T) { + // $neg = -10 (prefix on literal), $pos = -$neg = 10 (prefix on var) + src := ` + vars { + number $neg = -10 + number $pos = -$neg + } + send [USD/2 $pos] ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_PrefixMinusMonetary(t *testing.T) { + // $neg_mon = [USD/2 -10], -$neg_mon = [USD/2 10] + src := ` + vars { + monetary $neg_mon = [USD/2 -10] + monetary $pos_mon = -$neg_mon + } + send $pos_mon ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_Balance(t *testing.T) { + // $bal = balance(@src, USD/2) reads @src's balance (100), then sends it all + src := ` + vars { + monetary $bal = balance(@src, USD/2) + } + send $bal ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) +} + +func TestE2E_AccountInterpolation(t *testing.T) { + // destination = @users:<$id>:wallet, with $id = "alice" + src := ` + #![feature("experimental-account-interpolation")] + vars { + string $id = "alice" + } + send [USD/2 10] ( + source = @world + destination = @users:$id:wallet + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "users:alice:wallet", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_AccountInterpolationInt(t *testing.T) { + // destination = @account:<$n>, with $n = 42 + src := ` + #![feature("experimental-account-interpolation")] + vars { + number $n = 42 + } + send [USD/2 10] ( + source = @world + destination = @account:$n + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "account:42", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_BoundedOverdraft(t *testing.T) { + src := ` + send [USD/2 42] ( + source = @a allowing overdraft up to [USD/2 5] + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(40), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(42)}, + }, postings) +} + +func TestE2E_NestedDestination(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/2 to { + max [USD/2 10] to @x + remaining to @a + } + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "x", Asset: "USD/2", Amount: big.NewInt(10)}, + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(40)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(50)}, + }, postings) +} + +func TestE2E_SendAll(t *testing.T) { + src := `send [USD/2 *] (source = @a destination = @dest)` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(30), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(30)}, + }, postings) +} + +func TestE2E_UncappedBoundedOverdraft(t *testing.T) { + src := ` + send [USD/2 *] ( + source = @a allowing overdraft up to [USD/2 5] + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(40), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(45)}, + }, postings) +} + +func TestE2E_SendAllMultiSource(t *testing.T) { + // unbounded inorder: pull everything from each source in order and sum it + src := ` + send [USD/2 *] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(10), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(7), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(5)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(7)}, + }, postings) +} + +func TestE2E_SendAllNegativeOverdraftBoundClamped(t *testing.T) { + // a negative overdraft bound is clamped to 0 in the unbounded path, so only + // the positive balance is sent (mirrors the interpreter's NonNeg). + src := ` + send [COIN *] ( + source = @s allowing overdraft up to [COIN -10] + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "s", Asset: "COIN", Color: ""}: big.NewInt(1), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "s", Destination: "dest", Asset: "COIN", Amount: big.NewInt(1)}, + }, postings) +} + +func TestE2E_CapAssetMismatch(t *testing.T) { + src := ` + send [USD/2 100] ( + source = max [EUR/2 5] from @a + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_OverdraftAssetMismatch(t *testing.T) { + src := ` + send [USD/2 42] ( + source = @a allowing overdraft up to [EUR/2 5] + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_Save(t *testing.T) { + // save 30 of @a's 100, so the send-all only takes the remaining 70 + src := ` + save [USD/2 30] from @a + send [USD/2 *] (source = @a destination = @dest) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(70)}, + }, postings) +} + +func TestE2E_InternalVar(t *testing.T) { + src := ` + vars { account $acc = @src } + send [USD/2 10] (source = $acc destination = @dest) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_OverdraftFunction(t *testing.T) { + src := ` + #![feature("experimental-overdraft-function")] + vars { monetary $od = overdraft(@acc, USD/2) } + send $od (source = @world destination = @dest) + ` + // negative balance -> overdraft is the debt + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "acc", Asset: "USD/2", Color: ""}: big.NewInt(-100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) + + // positive balance -> overdraft is 0, nothing sent + postings = runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "acc", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{}, postings) +} + +func TestE2E_BalanceNegativeErrors(t *testing.T) { + src := ` + vars { monetary $b = balance(@acc, USD/2) } + send $b (source = @world destination = @dest) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "acc", Asset: "USD/2", Color: ""}: big.NewInt(-1), + }}) + require.IsType(t, vm.NegativeBalanceError{}, execErr) +} + +func TestE2E_DivideByZero(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/0 to @a + remaining kept + } + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.DivideByZeroError{}, execErr) +} + +func TestE2E_ColoredSource(t *testing.T) { + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "COIN", Color: ""}: big.NewInt(100), + {Account: "src", Asset: "COIN", Color: "RED"}: big.NewInt(30), + }} + + got := runE2E(t, ` + #![feature("experimental-asset-colors")] + send [COIN 10] ( + source = @src \ "RED" + destination = @dest + ) + `, store) + + want := []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "COIN", Color: "RED", Amount: big.NewInt(10)}, + } + requirePostingsEqual(t, want, got) +} + +func TestE2E_InvalidColor(t *testing.T) { + src := ` + #![feature("experimental-asset-colors")] + send [COIN 10] ( + source = @src \ "not a color" + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{}) + require.IsType(t, vm.InvalidColor{}, execErr) +} + +// countingStore is an e2eStore that records how many balances it was asked for. +type countingStore struct { + e2eStore + balanceCalls int +} + +func (s *countingStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { + s.balanceCalls++ + return s.e2eStore.GetBalance(ctx, account, asset, color) +} + +// The compiled world arm has no overdraft operand, which is what makes the pull +// unbounded and therefore free of Store round-trips. numscript_test.go asserts +// the same for the interpreter. +func TestE2E_WorldSourceReadsNoBalance(t *testing.T) { + src := `send [USD/2 100] (source = @world destination = @dest)` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + store := &countingStore{} + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, res.Postings) + require.Zero(t, store.balanceCalls, "a world source must not read any balance") +} + +// the run-time branch, not the literal, is what decides it +func TestE2E_DynamicWorldSourceReadsNoBalance(t *testing.T) { + src := ` + vars { account $src } + send [USD/2 100] (source = $src destination = @dest) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + enc, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + vars, err := enc.Encode(map[string]string{"src": "world"}) + require.NoError(t, err) + store := &countingStore{} + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, &vars, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, res.Postings) + require.Zero(t, store.balanceCalls, "a world source must not read any balance") +} + +// A send-all needs a bounded source to know how much "all" is, and @world is +// unbounded. The specs format has no expectation field for this error, so it is +// asserted here; the interpreter's twin is TestInvalidUnboundedWorldInSendAll. +func TestE2E_SendAllFromWorldErrors(t *testing.T) { + src := `send [USD/2 *] (source = @world destination = @dest)` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, nil, e2eStore{}) + + require.Equal(t, vm.InvalidUncappedSource{Account: "world"}, execErr) +} + +// same, but world is only known at run time, so the compiler cannot reject it +func TestE2E_SendAllFromDynamicWorldErrors(t *testing.T) { + src := ` + vars { account $src } + send [USD/2 *] (source = $src destination = @dest) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + enc, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + vars, err := enc.Encode(map[string]string{"src": "world"}) + require.NoError(t, err) + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, &vars, e2eStore{}) + + require.Equal(t, vm.InvalidUncappedSource{Account: "world"}, execErr) +} + +func runE2E(t *testing.T, src string, store e2eStore) []runtime.Posting { + t.Helper() + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr) + return res.Postings +} + +func requirePostingsEqual(t *testing.T, want, got []runtime.Posting) { + t.Helper() + require.Len(t, got, len(want)) + for i := range want { + w, g := want[i], got[i] + require.Equal(t, w.Source, g.Source, "posting[%d].Source", i) + require.Equal(t, w.Destination, g.Destination, "posting[%d].Destination", i) + require.Equal(t, w.Asset, g.Asset, "posting[%d].Asset", i) + require.Equal(t, w.Color, g.Color, "posting[%d].Color", i) + require.Zero(t, g.Amount.Cmp(w.Amount), "posting[%d].Amount: got %s want %s", i, g.Amount, w.Amount) + } +} + +// --- Allotment rounding, ported from internal/runtime/allotment_test.go ----- +// These pinned runtime.MakeAllotment before the split was lowered into pure +// instructions; they now pin the compiler's lowering of it. + +// A two-unit shortfall: 1/6,1/6,4/6 of 100 floors to 16,16,66 (sum 98), so the +// first two shares each get one unit back. +func TestE2E_AllotmentLeftoverTwoUnits(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/6 to @a + 1/6 to @b + remaining to @c + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(17)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(17)}, + {Source: "world", Destination: "c", Asset: "USD/2", Amount: big.NewInt(66)}, + }, postings) +} + +// An odd amount split in half: 7 -> 3,3 (sum 6), leftover unit to the earliest. +func TestE2E_AllotmentHalvesOfOddAmount(t *testing.T) { + src := ` + send [USD/2 7] ( + source = @world + destination = { + 1/2 to @a + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(4)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(3)}, + }, postings) +} + +// A single whole share: the lowering emits no fixup blocks at all for n == 1. +func TestE2E_AllotmentSinglePortionWhole(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + remaining to @a + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) +} + +// Percentages that divide exactly: no leftover, so no share is adjusted. +func TestE2E_AllotmentPercentagesDivideExactly(t *testing.T) { + src := ` + send [USD/2 10000] ( + source = @world + destination = { + 19/100 to @a + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(1900)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(8100)}, + }, postings) +} + +// Sevenths of 1001 floor awkwardly (143 + 286 + 572 = 1001 exactly here), the +// point being that the shares must always sum back to the amount. +func TestE2E_AllotmentPartsSumToAmount(t *testing.T) { + src := ` + send [USD/2 1001] ( + source = @world + destination = { + 1/7 to @a + 2/7 to @b + remaining to @c + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + + total := new(big.Int) + for _, p := range postings { + total.Add(total, p.Amount) + } + require.Zero(t, total.Cmp(big.NewInt(1001)), "shares sum to %s, want 1001 (%v)", total, postings) +} + +// Beyond int64: ~1e27+1 split in half, the odd unit going to the earliest share. +func TestE2E_AllotmentBeyondInt64(t *testing.T) { + src := ` + send [USD/2 1000000000000000000000000001] ( + source = @world + destination = { + 1/2 to @a + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + + amount, _ := new(big.Int).SetString("1000000000000000000000000001", 10) + half := new(big.Int).Div(amount, big.NewInt(2)) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: new(big.Int).Add(half, big.NewInt(1))}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: half}, + }, postings) +} + +// TestE2E_Oneof runs a compiled `oneof` end to end, which the IR snapshot tests +// cannot: they check the emitted instructions, not that executing them backtracks +// correctly. What matters here is that the single mark_pop at the join is reached +// on every path — the branch that covered the amount jumps straight to it, and the +// last branch falls through to it. +func TestE2E_Oneof(t *testing.T) { + src := ` + #![feature("experimental-oneof")] + send [USD/2 10] ( + source = oneof { + @a + @b + @c + } + destination = @dest + ) + ` + + t.Run("first branch covers it", func(t *testing.T) { + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2"}: big.NewInt(10), + {Account: "b", Asset: "USD/2"}: big.NewInt(10), + {Account: "c", Asset: "USD/2"}: big.NewInt(10), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) + }) + + t.Run("backtracks past a short branch", func(t *testing.T) { + // @a can only cover 3 of the 10, so its partial funding is discarded whole + // rather than combined with @b's + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2"}: big.NewInt(3), + {Account: "b", Asset: "USD/2"}: big.NewInt(10), + {Account: "c", Asset: "USD/2"}: big.NewInt(10), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) + }) + + t.Run("backtracks twice, to the last branch", func(t *testing.T) { + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2"}: big.NewInt(3), + {Account: "b", Asset: "USD/2"}: big.NewInt(9), + {Account: "c", Asset: "USD/2"}: big.NewInt(10), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) + }) + + t.Run("no branch covers it", func(t *testing.T) { + // the last branch is not rewound, so check_enough_funds reports what it got + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value, nil) + require.Nil(t, cErr) + _, execErr := vm.Exec(context.Background(), vm.NewVm(program), nil, e2eStore{ + balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2"}: big.NewInt(3), + {Account: "b", Asset: "USD/2"}: big.NewInt(4), + {Account: "c", Asset: "USD/2"}: big.NewInt(5), + }, + }) + require.IsType(t, vm.MissingFundsError{}, execErr) + }) +} + +// A oneof nested inside another must keep the two regions independent: the inner +// backtrack may not discard what the outer branch already pulled. +func TestE2E_OneofNested(t *testing.T) { + src := ` + #![feature("experimental-oneof")] + send [USD/2 10] ( + source = oneof { + { + @a + oneof { @b @c } + } + @d + } + destination = @dest + ) + ` + + t.Run("inner backtrack keeps the outer branch's funds", func(t *testing.T) { + // @a gives 4, so the inner oneof needs 6: @b has only 5 -> rewind -> @c + // covers it. @a's 4 must survive the inner rewind. + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2"}: big.NewInt(4), + {Account: "b", Asset: "USD/2"}: big.NewInt(5), + {Account: "c", Asset: "USD/2"}: big.NewInt(6), + {Account: "d", Asset: "USD/2"}: big.NewInt(10), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(4)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(6)}, + }, postings) + }) + + t.Run("outer backtrack discards the whole inner region too", func(t *testing.T) { + // the inorder branch tops out at 4+6=10... but with @c at 5 it reaches 9, + // so the outer oneof rewinds @a and @c together and takes @d instead + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2"}: big.NewInt(4), + {Account: "b", Asset: "USD/2"}: big.NewInt(3), + {Account: "c", Asset: "USD/2"}: big.NewInt(5), + {Account: "d", Asset: "USD/2"}: big.NewInt(10), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "d", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) + }) +} diff --git a/internal/compiler/scripts_test.go b/internal/compiler/scripts_test.go new file mode 100644 index 00000000..48eed882 --- /dev/null +++ b/internal/compiler/scripts_test.go @@ -0,0 +1,203 @@ +package compiler_test + +import ( + "context" + "encoding/json" + "maps" + "math/big" + "path/filepath" + "slices" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/specs_format" + "github.com/formancehq/numscript/internal/vm" + + "github.com/stretchr/testify/require" +) + +const scriptsFolder = "../interpreter/testdata/script-tests" + +// scriptsBlacklist lists spec files the compiler+VM can't run yet. What's left is +// the three experimental features the compiler has no lowering for: scopes, +// colors and scaling. Delete entries as features land, until it's empty. +var scriptsBlacklist = []string{ + "experimental/scoped-function/allotment.num", + "experimental/scoped-function/balance.num", + "experimental/scoped-function/capped.num", + "experimental/scoped-function/color-and-scope.num", + "experimental/scoped-function/overdraft.num", + "experimental/scoped-function/read-account-meta.num", + "experimental/scoped-function/save.num", + "experimental/scoped-function/set-account-meta.num", + "experimental/scoped-function/simple.num", + "experimental/asset-scaling/no-solution.num", + "experimental/asset-scaling/scaling-all-allotment.num", + "experimental/asset-scaling/scaling-allotment.num", + "experimental/asset-scaling/scaling-kept.num", + "experimental/asset-scaling/scaling-send-all.num", + "experimental/asset-scaling/scaling-with-oneof.num", + "experimental/asset-scaling/scaling.num", + "experimental/asset-scaling/update-swap-account-balance.num", +} + +func TestCompilerScripts(t *testing.T) { + rawSpecs, err := specs_format.ReadSpecsFiles([]string{scriptsFolder}) + require.NoError(t, err) + + for _, rawSpec := range rawSpecs { + rel, err := filepath.Rel(scriptsFolder, rawSpec.NumscriptPath) + require.NoError(t, err) + + t.Run(rel, func(t *testing.T) { + if slices.Contains(scriptsBlacklist, rel) { + t.Skip("blacklisted: not supported yet") + } + + var specs specs_format.Specs + require.NoError(t, json.Unmarshal(rawSpec.SpecsFileContent, &specs)) + + defer func() { + if r := recover(); r != nil { + t.Errorf("panic: %v", r) + } + }() + + runScriptSpec(t, specs, rawSpec.NumscriptContent) + }) + } +} + +func runScriptSpec(t *testing.T, specs specs_format.Specs, src string) { + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + featureFlags := make(map[string]struct{}, len(specs.FeatureFlags)) + for _, flag := range specs.FeatureFlags { + featureFlags[flag] = struct{}{} + } + + enc, program, cErr := compiler.Compile(parsed.Value, featureFlags) + require.Nil(t, cErr) + + hasFocused := slices.ContainsFunc(specs.TestCases, func(tc specs_format.TestCase) bool { + return tc.Focus + }) + + for _, tc := range specs.TestCases { + if tc.Skip || (hasFocused && !tc.Focus) { + continue + } + if tc.ExpectNegativeAmount { + // internal/vm has no negative-amount error: `send [USD/2 -1]` returns no + // error and no postings, where the interpreter returns NegativeAmountErr. + t.Logf("case %q: skipped, the VM has no negative-amount error", tc.It) + continue + } + + caseVars := map[string]string{} + maps.Copy(caseVars, specs.Vars) + maps.Copy(caseVars, tc.Vars) + vars, encErr := enc.Encode(caseVars) + require.NoError(t, encErr, "case %q: encode vars", tc.It) + + balances := specs_format.MergeBalances(specs.Balances, tc.Balances) + + machine := vm.NewVm(program) + store := scriptStore(balances, specs.Meta, tc.Meta) + res, execErr := vm.Exec(context.Background(), machine, &vars, store) + + if tc.ExpectMissingFunds { + require.IsType(t, vm.MissingFundsError{}, execErr, "case %q", tc.It) + continue + } + require.Nil(t, execErr, "case %q: unexpected error: %v", tc.It, execErr) + + if tc.ExpectPostings != nil { + require.Equal(t, tc.ExpectPostings, res.Postings, "case %q: expect.postings", tc.It) + } + + if tc.ExpectEndBalances != nil { + got := specs_format.EndBalances(res.Postings, balances) + require.True(t, interpreter.CompareBalances(tc.ExpectEndBalances, got), + "case %q: expect.endBalances: want %v, got %v", tc.It, tc.ExpectEndBalances, got) + } + + if tc.ExpectEndBalancesInclude != nil { + got := specs_format.EndBalances(res.Postings, balances) + require.True(t, interpreter.CompareBalancesIncluding(tc.ExpectEndBalancesInclude, got), + "case %q: expect.endBalances.include: want %v to be included in %v", tc.It, tc.ExpectEndBalancesInclude, got) + } + + if tc.ExpectMovements != nil { + got := specs_format.GetMovements(res.Postings) + require.True(t, specs_format.CompareMovements(tc.ExpectMovements, got), + "case %q: expect.movements: want %v, got %v", tc.It, tc.ExpectMovements, got) + } + + if tc.ExpectTxMeta != nil { + require.Equal(t, txMetaAsStrings(tc.ExpectTxMeta), res.Metadata, "case %q: expect.txMetadata", tc.It) + } + + if tc.ExpectAccountsMeta != nil { + require.Equal(t, accountsMetaAsStrings(tc.ExpectAccountsMeta), res.AccountsMetadata, + "case %q: expect.metadata", tc.It) + } + } +} + +// vmMetaValue projects a spec's typed metadata value onto the flat string the VM +// stores, since the compiler stringifies metadata values at compile time. Only +// String and AccountAddress need unwrapping: Value.String() quotes the former and +// prefixes the latter with '@'. +func vmMetaValue(v interpreter.Value) string { + switch v := v.(type) { + case interpreter.String: + return string(v) + case interpreter.AccountAddress: + return v.Name + default: + return v.String() + } +} + +func txMetaAsStrings(rows specs_format.ExpectedTxMeta) runtime.AccountMetadata { + out := runtime.AccountMetadata{} + for _, row := range rows { + out[row.Key] = vmMetaValue(row.Value) + } + return out +} + +func accountsMetaAsStrings(rows interpreter.SetAccountsMetadata) runtime.AccountsMetadata { + out := runtime.AccountsMetadata{} + for _, row := range rows { + if out[row.Account] == nil { + out[row.Account] = runtime.AccountMetadata{} + } + out[row.Account][row.Key] = vmMetaValue(row.Value) + } + return out +} + +func scriptStore(balances interpreter.Balances, metaOuter, metaInner interpreter.AccountsMetadata) e2eStore { + m := map[runtime.PairKey]*big.Int{} + for _, b := range balances { + m[runtime.PairKey{Account: b.Account, Asset: b.Asset, Color: b.Color}] = b.Amount + } + + meta := map[string]map[string]string{} + for _, src := range []interpreter.AccountsMetadata{metaOuter, metaInner} { + for _, row := range src { + if meta[row.Account] == nil { + meta[row.Account] = map[string]string{} + } + meta[row.Account][row.Key] = row.Value + } + } + + return e2eStore{balances: m, metadata: meta} +} diff --git a/internal/compiler/value.go b/internal/compiler/value.go new file mode 100644 index 00000000..172d52df --- /dev/null +++ b/internal/compiler/value.go @@ -0,0 +1,25 @@ +package compiler + +import "github.com/formancehq/numscript/internal/ir" + +// monetaryValue is a monetary-typed expression after codegen. The VM has no +// monetary register, so a monetary travels as two: the asset in a string +// register and the amount in an int register. +// +// Named fields rather than a returned (asset, amount) pair so that transposing +// the two is a build error instead of an ir.Typecheck error. +type monetaryValue struct { + Asset ir.Reg // str + Amount ir.Reg // int +} + +// value is a compiled expression of any type. Mon is set exactly for +// monetary-typed expressions, Reg for every other type. +type value struct { + Reg ir.Reg + Mon *monetaryValue +} + +func scalarValue(r ir.Reg) value { return value{Reg: r} } + +func monValue(m monetaryValue) value { return value{Mon: &m} } diff --git a/internal/compiler/vars_e2e_test.go b/internal/compiler/vars_e2e_test.go new file mode 100644 index 00000000..fea0177f --- /dev/null +++ b/internal/compiler/vars_e2e_test.go @@ -0,0 +1,154 @@ +package compiler_test + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +func TestE2E_ExternalVars(t *testing.T) { + src := ` + vars { + account $dest + monetary $m + } + send $m ( + source = @world + destination = $dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + enc, program, err := compiler.Compile(parsed.Value, nil) + require.NoError(t, err) + + vars, err := enc.Encode(map[string]string{ + "dest": "alice", + "m": "USD/2 100", + }) + require.NoError(t, err) + + machine := vm.NewVm(program) + res, execErr := vm.Exec(context.Background(), machine, &vars, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "world", Destination: "alice", Asset: "USD/2", Amount: big.NewInt(100)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +func TestE2E_InvalidInterpolatedAccount(t *testing.T) { + src := ` + #![feature("experimental-account-interpolation")] + vars { string $status } + set_tx_meta("k", @user:$status) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + enc, program, err := compiler.Compile(parsed.Value, nil) + require.NoError(t, err) + + vars, err := enc.Encode(map[string]string{"status": "!invalid acc.."}) + require.NoError(t, err) + + machine := vm.NewVm(program) + _, execErr := vm.Exec(context.Background(), machine, &vars, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.Equal(t, vm.InvalidAccountName{Name: "user:!invalid acc.."}, execErr) +} + +func compileEncoder(t *testing.T, src string) compiler.VarsEncoder { + t.Helper() + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + enc, _, err := compiler.Compile(parsed.Value, nil) + require.NoError(t, err) + return enc +} + +// A var of each type decomposes into its int/string slots, in declaration order. +func TestVarsEncoder_AllTypes(t *testing.T) { + enc := compileEncoder(t, ` + vars { + number $n + account $acc + portion $p + monetary $m + asset $a + string $s + } + send [COIN 0] (source = @world destination = @world) + `) + + vars, err := enc.Encode(map[string]string{ + "n": "42", + "acc": "alice", + "p": "1/4", + "m": "USD/2 100", + "a": "EUR", + "s": "hello", + }) + require.NoError(t, err) + + // str slots: acc, m.asset, a, s int slots: n, p.num, p.den, m.amount + require.Equal(t, []string{"alice", "USD/2", "EUR", "hello"}, vars.StringsPool) + require.Equal(t, []big.Int{ + *big.NewInt(42), *big.NewInt(1), *big.NewInt(4), *big.NewInt(100), + }, vars.IntsPool) +} + +func TestVarsEncoder_Errors(t *testing.T) { + enc := compileEncoder(t, ` + vars { number $n account $acc } + send [COIN 0] (source = @world destination = @world) + `) + + _, err := enc.Encode(map[string]string{"n": "1"}) + require.ErrorContains(t, err, "missing variable: $acc") + + _, err = enc.Encode(map[string]string{"n": "not-a-number", "acc": "alice"}) + require.ErrorContains(t, err, "variable $n") +} + +// Every var type validates its raw value, and the error names the variable. +func TestVarsEncoder_ErrorsPerType(t *testing.T) { + testCases := []struct { + typ string + raw string + msg string + }{ + {"number", "4.2", `invalid number: "4.2"`}, + {"account", "not an account", `invalid account: "not an account"`}, + {"asset", "usd", `invalid asset: "usd"`}, + {"portion", "nope", "invalid format"}, + {"portion", "200%", "between 0% and 100%"}, + {"monetary", "USD/2", `invalid monetary: "USD/2"`}, + {"monetary", "usd 1", `invalid asset: "usd"`}, + {"string", "anything goes", ""}, + } + + for _, tc := range testCases { + t.Run(tc.typ+" "+tc.raw, func(t *testing.T) { + enc := compileEncoder(t, ` + vars { `+tc.typ+` $v } + send [COIN 0] (source = @world destination = @world) + `) + _, err := enc.Encode(map[string]string{"v": tc.raw}) + if tc.msg == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, "variable $v") + require.ErrorContains(t, err, tc.msg) + }) + } +} diff --git a/internal/compiler/vars_encoder.go b/internal/compiler/vars_encoder.go new file mode 100644 index 00000000..29f06220 --- /dev/null +++ b/internal/compiler/vars_encoder.go @@ -0,0 +1,89 @@ +package compiler + +import ( + "fmt" + "math/big" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/typecheck" + "github.com/formancehq/numscript/internal/vm" +) + +type VarsEncoder struct { + decls []varDecl + nStr int + nInt int +} + +type varDecl struct { + name string + typ typecheck.Type +} + +// TODO review AI blob +func (e VarsEncoder) Encode(vars map[string]string) (vm.Vars, error) { + strs := make([]string, 0, e.nStr) + ints := make([]big.Int, 0, e.nInt) + + for _, d := range e.decls { + raw, ok := vars[d.name] + if !ok { + return vm.Vars{}, fmt.Errorf("missing variable: $%s", 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{StringsPool: strs, IntsPool: ints}, nil +} + +// TODO review AI blob +func appendVar(strs []string, ints []big.Int, typ typecheck.Type, raw string) ([]string, []big.Int, error) { + switch typ { + case typecheck.TypeNumber: + n, ok := runtime.ParseNumber(raw) + if !ok { + return strs, ints, fmt.Errorf("invalid number: %q", raw) + } + ints = append(ints, *n) + + case typecheck.TypeString: + strs = append(strs, raw) + + case typecheck.TypeAccount: + if !runtime.ValidateAccount(raw) { + return strs, ints, fmt.Errorf("invalid account: %q", raw) + } + strs = append(strs, raw) + + case typecheck.TypeAsset: + if !runtime.ValidateAsset(raw) { + return strs, ints, fmt.Errorf("invalid asset: %q", raw) + } + strs = append(strs, raw) + + case typecheck.TypePortion: + r, err := runtime.ParsePortion(raw) + if err != nil { + return strs, ints, err + } + ints = append(ints, *r.Num(), *r.Denom()) + + case typecheck.TypeMonetary: + asset, amount, err := runtime.ParseMonetary(raw) + if err != nil { + return strs, ints, err + } + strs = append(strs, asset) + ints = append(ints, *amount) + + default: + panic("unexpected var type: " + typ) + } + + return strs, ints, nil +} diff --git a/internal/interpreter/accounts_metadata_test.go b/internal/interpreter/accounts_metadata_test.go index 91d0f5ee..f19d4319 100644 --- a/internal/interpreter/accounts_metadata_test.go +++ b/internal/interpreter/accounts_metadata_test.go @@ -3,6 +3,7 @@ package interpreter import ( "testing" + "github.com/formancehq/numscript/internal/runtime" "github.com/gkampitakis/go-snaps/snaps" "github.com/stretchr/testify/require" ) @@ -47,19 +48,19 @@ func TestCompareSetAccountsMetadata(t *testing.T) { func TestScopeValidation(t *testing.T) { t.Run("valid scopes", func(t *testing.T) { - require.True(t, checkScopeName("")) - require.True(t, checkScopeName("myscope")) - require.True(t, checkScopeName("x")) - require.True(t, checkScopeName("x1")) - require.True(t, checkScopeName("my_scope_with_underscores")) + require.True(t, runtime.ValidateScope("")) + require.True(t, runtime.ValidateScope("myscope")) + require.True(t, runtime.ValidateScope("x")) + require.True(t, runtime.ValidateScope("x1")) + require.True(t, runtime.ValidateScope("my_scope_with_underscores")) }) t.Run("invalid scopes", func(t *testing.T) { - require.False(t, checkScopeName("!")) - require.False(t, checkScopeName("$")) - require.False(t, checkScopeName("UPPERCASE")) - require.False(t, checkScopeName("dash-case")) - require.False(t, checkScopeName("colons:within")) + require.False(t, runtime.ValidateScope("!")) + require.False(t, runtime.ValidateScope("$")) + require.False(t, runtime.ValidateScope("UPPERCASE")) + require.False(t, runtime.ValidateScope("dash-case")) + require.False(t, runtime.ValidateScope("colons:within")) }) } diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index 33f5e1fe..57e6af22 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -4,6 +4,7 @@ import ( "slices" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" "github.com/formancehq/numscript/internal/utils" ) @@ -68,22 +69,11 @@ func (st *programState) batchQuery(account AccountAddress, asset Asset, color St } func (st *programState) runBalancesQuery() error { - filteredQuery := st.CachedBalances.filterQuery(st.CurrentBalanceQuery) - - // avoid updating balances if we don't need to fetch new data - if len(filteredQuery) == 0 { - return nil - } - - queriedBalances, err := st.Store.GetBalances(st.ctx, filteredQuery) - if err != nil { + if err := fetchAndPrewarm(st.ctx, st.Store, st.rs, st.CurrentBalanceQuery); err != nil { return err } // reset batch query st.CurrentBalanceQuery = BalanceQuery{} - - st.CachedBalances.Merge(queriedBalances) - return nil } @@ -110,7 +100,7 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr } // NOTE we don't query the swap account's balance - st.batchQuery(account, assetToScaledAsset(st.CurrentAsset), "") + st.batchQuery(account, Asset(runtime.AssetToScaledAsset(string(st.CurrentAsset))), "") return nil case *parser.SourceOverdraft: diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 3b2b92aa..32a162a7 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -7,25 +7,73 @@ import ( "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" ) +// zeroStore backs the runtime.RunState's lazy balance fallback. The interpreter +// fetches every needed balance through its own scope-aware Store and Prewarms it +// into the runtime, treating any un-fetched (account, scope, asset, color) as +// zero — exactly the semantics this store provides. +type zeroStore struct{} + +func (zeroStore) GetBalance(account, asset, color string) (*big.Int, error) { + return new(big.Int), nil +} + +// fetchAndPrewarm fetches the not-yet-cached tuples of query from the scope-aware +// Store in one round-trip and seeds them into rs, so later reads hit the cache. +// Shared by the single-key balance reader and the batched pre-execution pass. +func fetchAndPrewarm(ctx context.Context, store Store, rs *runtime.RunState, query BalanceQuery) error { + var missing BalanceQuery + for _, item := range query { + if !rs.Has(item.Account, item.Scope, item.Asset, item.Color) { + missing = append(missing, item) + } + } + if len(missing) == 0 { + return nil + } + rows, err := store.GetBalances(ctx, missing) + if err != nil { + return err + } + seed := make(map[runtime.PairKey]*big.Int, len(rows)) + for _, row := range rows { + seed[runtime.PairKey{Account: row.Account, Scope: row.Scope, Asset: row.Asset, Color: row.Color}] = row.Amount + } + rs.Prewarm(seed) + return nil +} + +// evalEnv is the environment for evaluating expressions. It reads metadata +// straight from the Store (cached), but balance reads are injected via getBalance +// because their policy differs by caller: script execution reads running balances +// from rs, while dependency resolution only needs the read recorded. Evaluation +// never touches the funds engine directly. type evalEnv struct { ctx context.Context Store Store FeatureFlags map[string]struct{} + vars map[string]Value - vars map[string]Value - CachedBalances InternalBalances + getBalance func(account AccountAddress, asset Asset) (*big.Int, InterpreterError) CachedAccountsMeta InternalAccountsMetadata } -func newEvalEnv(ctx context.Context, store Store, featureFlags map[string]struct{}, varDecls *parser.VarDeclarations, rawVars map[string]string) (evalEnv, InterpreterError) { +func newEvalEnv( + ctx context.Context, + store Store, + featureFlags map[string]struct{}, + getBalance func(AccountAddress, Asset) (*big.Int, InterpreterError), + varDecls *parser.VarDeclarations, + rawVars map[string]string, +) (evalEnv, InterpreterError) { env := evalEnv{ ctx: ctx, Store: store, FeatureFlags: featureFlags, vars: map[string]Value{}, - CachedBalances: InternalBalances{}, + getBalance: getBalance, CachedAccountsMeta: InternalAccountsMetadata{}, } if err := bindVars(&env, varDecls, rawVars); err != nil { @@ -45,20 +93,29 @@ func (env *evalEnv) checkFeatureFlag(flag string) InterpreterError { return ExperimentalFeature{FlagName: flag} } -func (env *evalEnv) getBalance(account AccountAddress, asset Asset) (*big.Int, InterpreterError) { - color := String("") - if !env.CachedBalances.has(account, string(asset), string(color)) { - rows, err := env.Store.GetBalances(env.ctx, BalanceQuery{ +// newBalanceGetter builds the balance reader used during evaluation: a lazy, +// write-through fetch over the batched, scope-aware Store into rs, so a mid-script +// balance() sees running balances mutated by funds execution (both share rs). +func newBalanceGetter(ctx context.Context, store Store, rs *runtime.RunState) func(AccountAddress, Asset) (*big.Int, InterpreterError) { + return func(account AccountAddress, asset Asset) (*big.Int, InterpreterError) { + color := String("") + query := BalanceQuery{ {Account: account.Name, Asset: string(asset), Color: string(color), Scope: account.Scope}, - }) + } + if err := fetchAndPrewarm(ctx, store, rs, query); err != nil { + return nil, QueryBalanceError{WrappedError: err} + } + // rs is backed by zeroStore (never errors); the fetch above already + // surfaced any real store error. + bal, err := rs.GetAccountBalance(account.Name, account.Scope, string(asset), string(color)) if err != nil { return nil, QueryBalanceError{WrappedError: err} } - env.CachedBalances.Merge(rows) + return bal, nil } - return env.CachedBalances.fetchBalance(account, asset, color), nil } +// getMetadata is a lazy, cached read of account metadata from the Store. func (env *evalEnv) getMetadata(account AccountAddress, key string) (string, bool, InterpreterError) { if !env.CachedAccountsMeta.has(account, key) { rows, err := env.Store.GetAccountsMetadata(env.ctx, MetadataQuery{ @@ -69,7 +126,6 @@ func (env *evalEnv) getMetadata(account AccountAddress, key string) (string, boo } env.CachedAccountsMeta.Merge(rows) } - value, ok := env.CachedAccountsMeta.Get(account, key) return value, ok, nil } @@ -234,8 +290,7 @@ func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, Interp return "", err } - isValidColor := colorRe.Match([]byte(string(color))) - if !isValidColor { + if !runtime.ValidateColor(string(color)) { return "", InvalidColor{ Range: colorExpr.GetRange(), Color: string(color), diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 73efeb11..0079f2ab 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -6,6 +6,7 @@ import ( "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" ) func evaluateFnCall(env *evalEnv, type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { @@ -230,7 +231,7 @@ func scoped( return nil, err } - if !checkScopeName(scopeStr) { + if !runtime.ValidateScope(scopeStr) { return nil, InvalidScope{Scope: scopeStr} } diff --git a/internal/interpreter/funds_queue.go b/internal/interpreter/funds_queue.go deleted file mode 100644 index d4ad6dd9..00000000 --- a/internal/interpreter/funds_queue.go +++ /dev/null @@ -1,188 +0,0 @@ -package interpreter - -import ( - "math/big" -) - -type Sender struct { - Account AccountAddress - Amount *big.Int - Color string -} - -type queue[T any] struct { - Head T - Tail *queue[T] - - // Instead of keeping a single ref of the lastCell and updating the invariant on every push/pop operation, - // we keep a cache of the last cell on every cell. - // This makes code much easier and we don't risk breaking the invariant and producing wrong results and other subtle issues - // - // While, unlike keeping a single reference (like golang's queue `container/list` package does), this is not always O(1), - // the amortized time should still be O(1) (the number of steps of traversal while searching the last elem is not higher than the number of .Push() calls) - lastCell *queue[T] -} - -func (s *queue[T]) getLastCell() *queue[T] { - // check if this is the last cell without reading cache first - if s.Tail == nil { - return s - } - - // if not, check if cache is present - if s.lastCell != nil { - // even if it is, it may be a stale value (as more values could have been pushed), so we check the value recursively - lastCell := s.lastCell.getLastCell() - // we do path compression so that next time we get the path immediately - s.lastCell = lastCell - return lastCell - } - - // if no last value is cached, we traverse recursively to find it - s.lastCell = s.Tail.getLastCell() - return s.lastCell -} - -func fromSlice[T any](slice []T) *queue[T] { - var ret *queue[T] - // TODO use https://pkg.go.dev/slices#Backward in golang 1.23 - for i := len(slice) - 1; i >= 0; i-- { - ret = &queue[T]{ - Head: slice[i], - Tail: ret, - } - } - return ret -} - -type fundsQueue struct { - senders *queue[Sender] -} - -func newFundsQueue(senders []Sender) fundsQueue { - return fundsQueue{ - senders: fromSlice(senders), - } -} - -func (s *fundsQueue) compactTop() { - for s.senders != nil && s.senders.Tail != nil { - - first := s.senders.Head - second := s.senders.Tail.Head - - if second.Amount.Cmp(big.NewInt(0)) == 0 { - s.senders = &queue[Sender]{Head: first, Tail: s.senders.Tail.Tail} - continue - } - - if first.Account != second.Account || first.Color != second.Color { - return - } - - s.senders = &queue[Sender]{ - Head: Sender{ - Account: first.Account, - Color: first.Color, - Amount: new(big.Int).Add(first.Amount, second.Amount), - }, - Tail: s.senders.Tail.Tail, - } - } -} - -func (s *fundsQueue) PullAll() []Sender { - var senders []Sender - for s.senders != nil { - senders = append(senders, s.senders.Head) - s.senders = s.senders.Tail - } - return senders -} - -func (s *fundsQueue) Push(senders ...Sender) { - newTail := fromSlice(senders) - if s.senders == nil { - s.senders = newTail - } else { - cell := s.senders.getLastCell() - cell.Tail = newTail - } -} - -func (s *fundsQueue) PullAnything(requiredAmount *big.Int) []Sender { - return s.Pull(requiredAmount, nil) -} - -func (s *fundsQueue) PullColored(requiredAmount *big.Int, color string) []Sender { - return s.Pull(requiredAmount, &color) -} -func (s *fundsQueue) PullUncolored(requiredAmount *big.Int) []Sender { - return s.PullColored(requiredAmount, "") -} - -func (s *fundsQueue) Pull(requiredAmount *big.Int, color *string) []Sender { - // clone so that we can manipulate this arg - requiredAmount = new(big.Int).Set(requiredAmount) - - // TODO preallocate for perfs - var out []Sender - - for requiredAmount.Cmp(big.NewInt(0)) != 0 && s.senders != nil { - s.compactTop() - - available := s.senders.Head - s.senders = s.senders.Tail - - if color != nil && available.Color != *color { - out1 := s.Pull(requiredAmount, color) - s.senders = &queue[Sender]{ - Head: available, - Tail: s.senders, - } - out = append(out, out1...) - break - } - - switch available.Amount.Cmp(requiredAmount) { - case -1: // not enough: - out = append(out, available) - requiredAmount.Sub(requiredAmount, available.Amount) - - case 1: // more than enough - s.senders = &queue[Sender]{ - Head: Sender{ - Account: available.Account, - Color: available.Color, - Amount: new(big.Int).Sub(available.Amount, requiredAmount), - }, - Tail: s.senders, - } - fallthrough - - case 0: // exactly the same - out = append(out, Sender{ - Account: available.Account, - Color: available.Color, - Amount: new(big.Int).Set(requiredAmount), - }) - return out - } - - } - - return out -} - -// Clone the queue so that you can safely mutate one without mutating the other -func (s fundsQueue) Clone() fundsQueue { - fq := newFundsQueue(nil) - - senders := s.senders - for senders != nil { - fq.Push(senders.Head) - senders = senders.Tail - } - - return fq -} diff --git a/internal/interpreter/funds_queue_test.go b/internal/interpreter/funds_queue_test.go deleted file mode 100644 index 77e24c96..00000000 --- a/internal/interpreter/funds_queue_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package interpreter - -import ( - "math/big" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestEnoughBalance(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(100)}, - }) - - out := queue.PullAnything(big.NewInt(2)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - }, out) - -} - -func TestPush(t *testing.T) { - queue := newFundsQueue(nil) - queue.Push(Sender{Account: AccountAddress{Name: "acc"}, Amount: big.NewInt(100)}) - - out := queue.PullUncolored(big.NewInt(20)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "acc"}, Amount: big.NewInt(20)}, - }, out) - -} - -func TestSimple(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(5)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(3)}, - }, out) - - out = queue.PullAnything(big.NewInt(7)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(7)}, - }, out) -} - -func TestPullZero(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(0)) - require.Equal(t, []Sender(nil), out) -} - -func TestCompactFunds(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(5)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, - }, out) -} - -func TestCompactFunds3Times(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(3)}, - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(1)}, - }) - - out := queue.PullAnything(big.NewInt(6)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(6)}, - }, out) -} - -func TestCompactFundsWithEmptySender(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(0)}, - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(5)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, - }, out) -} - -func TestMissingFunds(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - }) - - out := queue.PullAnything(big.NewInt(300)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - }, out) -} - -func TestNoZeroLeftovers(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(15)}, - }) - - queue.PullAnything(big.NewInt(10)) - - out := queue.PullAnything(big.NewInt(15)) - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(15)}, - }, out) -} - -func TestReconcileColoredManyDestPerSender(t *testing.T) { - queue := newFundsQueue([]Sender{ - {AccountAddress{Name: "src"}, big.NewInt(10), "X"}, - }) - - out := queue.PullColored(big.NewInt(5), "X") - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "src"}, Amount: big.NewInt(5), Color: "X"}, - }, out) - - out = queue.PullColored(big.NewInt(5), "X") - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "src"}, Amount: big.NewInt(5), Color: "X"}, - }, out) - -} - -func TestPullColored(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(1), Color: "red"}, - {Account: AccountAddress{Name: "s3"}, Amount: big.NewInt(10)}, - {Account: AccountAddress{Name: "s4"}, Amount: big.NewInt(2), Color: "red"}, - {Account: AccountAddress{Name: "s5"}, Amount: big.NewInt(5)}, - }) - - out := queue.PullColored(big.NewInt(2), "red") - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(1), Color: "red"}, - {Account: AccountAddress{Name: "s4"}, Amount: big.NewInt(1), Color: "red"}, - }, out) - - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, - {Account: AccountAddress{Name: "s3"}, Amount: big.NewInt(10)}, - {Account: AccountAddress{Name: "s4"}, Amount: big.NewInt(1), Color: "red"}, - {Account: AccountAddress{Name: "s5"}, Amount: big.NewInt(5)}, - }, queue.PullAll()) -} - -func TestPullColoredComplex(t *testing.T) { - queue := newFundsQueue([]Sender{ - {AccountAddress{Name: "s1"}, big.NewInt(1), "c1"}, - {AccountAddress{Name: "s2"}, big.NewInt(1), "c2"}, - }) - - out := queue.PullColored(big.NewInt(1), "c2") - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(1), Color: "c2"}, - }, out) -} - -func TestClone(t *testing.T) { - - fq := newFundsQueue([]Sender{ - {AccountAddress{Name: "s1"}, big.NewInt(10), ""}, - }) - - cloned := fq.Clone() - - fq.PullAll() - - require.Equal(t, []Sender{ - {AccountAddress{Name: "s1"}, big.NewInt(10), ""}, - }, cloned.PullAll()) - -} - -func TestCompactFundsAndPush(t *testing.T) { - noCol := "" - - queue := newFundsQueue([]Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, - }) - - queue.Pull(big.NewInt(1), &noCol) - - queue.Push(Sender{ - Account: AccountAddress{Name: "pushed"}, - Amount: big.NewInt(42), - }) - - out := queue.PullAll() - require.Equal(t, []Sender{ - {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(11)}, - {Account: AccountAddress{Name: "pushed"}, Amount: big.NewInt(42)}, - }, out) -} diff --git a/internal/interpreter/internal_accounts_metadata.go b/internal/interpreter/internal_accounts_metadata.go index 975d8081..5779c7a8 100644 --- a/internal/interpreter/internal_accounts_metadata.go +++ b/internal/interpreter/internal_accounts_metadata.go @@ -11,7 +11,7 @@ type metadataKey struct { // An internal representation of the account metadata. Used to cache metadata we get from external store. // Whereas the external representation (interpreter.AccountsMetadata) is user-facing and a stable contract, // this one is used internally by the runtime, and could change over time, for example to add more indexes for faster lookups. -// It mirrors InternalBalances: keyed by the (account, scope) pair, holding that account's (key -> value) entries. +// It is keyed by the (account, scope) pair, holding that account's (key -> value) entries. type InternalAccountsMetadata map[AccountAddress]map[string]string // Get the (account, key) metadata value from the cache. diff --git a/internal/interpreter/internal_balances.go b/internal/interpreter/internal_balances.go deleted file mode 100644 index 6e45929b..00000000 --- a/internal/interpreter/internal_balances.go +++ /dev/null @@ -1,115 +0,0 @@ -package interpreter - -import "math/big" - -// An internal representation of the balances. Used to cache balances we get from external store. -// Whereas the external representation (interpreter.Balances) is user-facing and be a stable contract, -// (for example, allowing more columns if we need an higher level of fungibility), this one is used internally by the runtime, and -// could change over time, for example to add more indexes for faster lookups -type InternalBalances map[AccountAddress][]AccountBalance - -// A single balance entry for an account: an (asset, color) pair and its amount. -type AccountBalance struct { - Asset string - Color string - Amount *big.Int -} - -func FromBalancesRows(b Balances) InternalBalances { - out := make(InternalBalances, len(b)) - for _, row := range b { - amount := new(big.Int) // clone so the map doesn't alias the slice's *big.Int - if row.Amount != nil { - amount.Set(row.Amount) - } - // the cache is keyed by the (account, scope) pair; the scope is part of the - // key, so entries don't repeat it as a field - key := AccountAddress{Name: row.Account, Scope: row.Scope} - out[key] = append(out[key], AccountBalance{ - Asset: row.Asset, - Color: row.Color, - Amount: amount, - }) - } - return out -} - -func (b InternalBalances) DeepClone() InternalBalances { - cloned := make(InternalBalances, len(b)) - for account, entries := range b { - clonedEntries := make([]AccountBalance, len(entries)) - for i, e := range entries { - clonedEntries[i] = AccountBalance{ - Asset: e.Asset, - Color: e.Color, - Amount: new(big.Int).Set(e.Amount), - } - } - cloned[account] = clonedEntries - } - return cloned -} - -// Get the (account, asset, color) balance from the cache. -// If it is not present, it writes a zero balance in it and returns it. -func (b InternalBalances) fetchBalance(account AccountAddress, asset Asset, color String) *big.Int { - for i := range b[account] { - entry := &b[account][i] - if entry.Asset == string(asset) && entry.Color == string(color) { - return entry.Amount - } - } - - amount := new(big.Int) - b[account] = append(b[account], AccountBalance{ - Asset: string(asset), - Color: string(color), - Amount: amount, - }) - return amount -} - -// Set assigns amount to the (account, asset, color) balance. -func (b InternalBalances) Set(account AccountAddress, asset string, color string, amount *big.Int) { - for i := range b[account] { - if b[account][i].Asset == asset && b[account][i].Color == color { - b[account][i].Amount = amount - return - } - } - b[account] = append(b[account], AccountBalance{ - Asset: asset, - Color: color, - Amount: amount, - }) -} - -func (b InternalBalances) has(account AccountAddress, asset string, color string) bool { - for _, entry := range b[account] { - if entry.Asset == asset && entry.Color == color { - return true - } - } - return false -} - -// given a BalanceQuery, return a new query which only contains needed -// (account, asset, color) tuples (that is, the ones that aren't already cached) -func (b InternalBalances) filterQuery(q BalanceQuery) BalanceQuery { - filteredQuery := BalanceQuery{} - for _, item := range q { - key := AccountAddress{Name: item.Account, Scope: item.Scope} - if !b.has(key, item.Asset, item.Color) { - filteredQuery = append(filteredQuery, item) - } - } - return filteredQuery -} - -// Merge the queried balance rows into the cache -func (b InternalBalances) Merge(update []BalanceRow) { - for _, row := range update { - key := AccountAddress{Name: row.Account, Scope: row.Scope} - b.Set(key, row.Asset, row.Color, row.Amount) - } -} diff --git a/internal/interpreter/internal_balances_test.go b/internal/interpreter/internal_balances_test.go deleted file mode 100644 index e8e38735..00000000 --- a/internal/interpreter/internal_balances_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package interpreter - -import ( - "math/big" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFilterQuery(t *testing.T) { - fullBalance := InternalBalances{ - AccountAddress{Name: "alice"}: { - {Asset: "EUR/2", Amount: big.NewInt(1)}, - {Asset: "USD/2", Amount: big.NewInt(2)}, - }, - AccountAddress{Name: "bob"}: { - {Asset: "BTC", Amount: big.NewInt(3)}, - }, - } - - filteredQuery := fullBalance.filterQuery(BalanceQuery{ - {Account: "alice", Asset: "GBP/2"}, - {Account: "alice", Asset: "YEN"}, - {Account: "alice", Asset: "EUR/2"}, - {Account: "bob", Asset: "BTC"}, - {Account: "charlie", Asset: "ETH"}, - }) - - require.Equal(t, BalanceQuery{ - {Account: "alice", Asset: "GBP/2"}, - {Account: "alice", Asset: "YEN"}, - {Account: "charlie", Asset: "ETH"}, - }, filteredQuery) -} - -func TestBalancesFirstDuplicate(t *testing.T) { - // no duplicate: same account/asset but different color are distinct keys - _, ok := Balances{ - {Account: "alice", Asset: "USD/2", Amount: big.NewInt(1)}, - {Account: "alice", Asset: "EUR/2", Amount: big.NewInt(2)}, - {Account: "alice", Asset: "USD/2", Color: "X", Amount: big.NewInt(3)}, - {Account: "bob", Asset: "USD/2", Amount: big.NewInt(4)}, - }.FirstDuplicate() - require.False(t, ok) - - // duplicate (account, asset, color), even with a different amount - dup, ok := Balances{ - {Account: "alice", Asset: "USD/2", Amount: big.NewInt(1)}, - {Account: "alice", Asset: "USD/2", Amount: big.NewInt(99)}, - }.FirstDuplicate() - require.True(t, ok) - require.Equal(t, BalanceRow{Account: "alice", Asset: "USD/2", Amount: big.NewInt(99)}, dup) -} - -func TestCloneBalances(t *testing.T) { - fullBalance := InternalBalances{ - AccountAddress{Name: "alice"}: { - {Asset: "EUR/2", Amount: big.NewInt(1)}, - {Asset: "USD/2", Amount: big.NewInt(2)}, - }, - AccountAddress{Name: "bob"}: { - {Asset: "BTC", Amount: big.NewInt(3)}, - }, - } - - cloned := fullBalance.DeepClone() - - // USD/2 is the second entry for alice (index 1). - fullBalance[AccountAddress{Name: "alice"}][1].Amount.Set(big.NewInt(42)) - - require.Equal(t, big.NewInt(2), cloned[AccountAddress{Name: "alice"}][1].Amount) -} diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index ed6f8c50..d817c5b1 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -4,13 +4,13 @@ import ( "context" "maps" "math/big" - "regexp" "slices" "strings" "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" "github.com/formancehq/numscript/internal/utils" ) @@ -23,30 +23,10 @@ type InterpreterError interface { type Metadata = map[string]Value -type Posting struct { - Source string `json:"source"` - SourceScope string `json:"sourceScope,omitempty"` - Destination string `json:"destination"` - DestinationScope string `json:"destinationScope,omitempty"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Color string `json:"color,omitempty"` -} +type Posting = runtime.Posting -// newPosting builds a Posting from the source and destination addresses, -// exposing each address's account and scope as the separate fields the posting -// contract uses. -func newPosting(source AccountAddress, destination AccountAddress, amount *big.Int, asset string, color string) Posting { - return Posting{ - Source: source.Name, - SourceScope: source.Scope, - Destination: destination.Name, - DestinationScope: destination.Scope, - Amount: amount, - Asset: asset, - Color: color, - } -} +// AccountBalance is a single (asset, color, amount) balance entry for an account. +type AccountBalance = runtime.AccountBalance type ExecutionResult struct { Postings []Posting `json:"postings"` @@ -65,7 +45,7 @@ func parseMonetary(source string) (Monetary, InterpreterError) { asset := parts[0] rawAmount := parts[1] - n, ok := new(big.Int).SetString(rawAmount, 10) + n, ok := runtime.ParseNumber(rawAmount) if !ok { return Monetary{}, InvalidNumberLiteral{Source: rawAmount} } @@ -98,7 +78,7 @@ func parseVar(type_ string, rawValue string, r parser.Range) (Value, Interpreter case analysis.TypeAsset: return NewAsset(rawValue) case analysis.TypeNumber: - n, ok := new(big.Int).SetString(rawValue, 10) + n, ok := runtime.ParseNumber(rawValue) if !ok { return nil, InvalidNumberLiteral{Source: rawValue} } @@ -119,28 +99,6 @@ func evaluateVarOrigin(env *evalEnv, type_ string, expr parser.ValueExpr) (Value return evaluateExpr(env, expr) } -const accountSegmentRegex = "[a-zA-Z0-9_-]+" - -var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") - -// https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go -func checkAccountName(addr string) bool { - return accountNameRegex.Match([]byte(addr)) -} - -var assetNameRegexp = regexp.MustCompile(`^[A-Z][A-Z0-9]{0,16}(_[A-Z]{1,16})?(\/\d{1,6})?$`) - -// https://github.com/formancehq/ledger/blob/main/pkg/assets/asset.go -func checkAssetName(v string) bool { - return assetNameRegexp.Match([]byte(v)) -} - -var scopeRegex = regexp.MustCompile(`^[a-z0-9_]*$`) - -func checkScopeName(scope string) bool { - return scopeRegex.MatchString(scope) -} - // Check the following invariants: // - no negative postings // - no invalid account names @@ -149,9 +107,9 @@ func checkPostingInvariants(posting Posting) InterpreterError { isAmtNegative := posting.Amount.Cmp(big.NewInt(0)) == -1 isInvalidPosting := (isAmtNegative || - !checkAssetName(posting.Asset) || - !checkAccountName(posting.Source) || - !checkAccountName(posting.Destination)) + !runtime.ValidateAsset(posting.Asset) || + !runtime.ValidateAccount(posting.Source) || + !runtime.ValidateAccount(posting.Destination)) if isInvalidPosting { return InternalError{Posting: posting} @@ -182,17 +140,23 @@ func RunProgram( flagSet[flag.String] = struct{}{} } - env, err := newEvalEnv(ctx, store, flagSet, program.Vars, vars) + rs := runtime.New(zeroStore{}) + env, err := newEvalEnv( + ctx, + store, + flagSet, + newBalanceGetter(ctx, store, rs), + program.Vars, vars, + ) if err != nil { return nil, err } st := programState{ evalEnv: env, + rs: rs, TxMeta: make(map[string]Value), SetAccountsMeta: internalSetAccountsMeta{}, - Postings: make([]Posting, 0), - fundsQueue: newFundsQueue(nil), CurrentBalanceQuery: BalanceQuery{}, } @@ -216,7 +180,8 @@ func RunProgram( } } - for _, posting := range st.Postings { + postings := st.rs.GetPostings() + for _, posting := range postings { err := checkPostingInvariants(posting) if err != nil { return nil, err @@ -224,7 +189,7 @@ func RunProgram( } res := &ExecutionResult{ - Postings: st.Postings, + Postings: postings, Metadata: st.TxMeta, AccountsMetadata: st.SetAccountsMeta.toRows(), } @@ -234,37 +199,23 @@ func RunProgram( type programState struct { evalEnv + // rs owns the funds state: the write-through balance cache (seeded via + // Prewarm from the batched Store fetch), the FIFO funding-source queue, and + // the emitted postings. evalEnv's getBalance reader closes over this same rs. + rs *runtime.RunState + // Asset of the send statement currently being executed. // // its value is undefined outside of send statements execution CurrentAsset Asset - TxMeta map[string]Value - Postings []Posting - fundsQueue fundsQueue + TxMeta map[string]Value SetAccountsMeta internalSetAccountsMeta CurrentBalanceQuery BalanceQuery } -func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, color String) { - monetaryBi := big.Int(monetary) - - if monetaryBi.Cmp(big.NewInt(0)) == 0 { - return - } - - balance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, color) - balance.Sub(balance, &monetaryBi) - - st.fundsQueue.Push(Sender{ - Account: name, - Amount: &monetaryBi, - Color: string(color), - }) -} - // Append a posting without checking if account has enough balance. // Updates both source and destination balances. // Noop if the amount is zero @@ -273,45 +224,29 @@ func (st *programState) forcePushPostingUncolored( destination AccountAddress, amount MonetaryInt, asset Asset, -) { +) InterpreterError { amtBi := big.Int(amount) - - if amtBi.Sign() == 0 { - return + if err := st.rs.ForcePosting(source.Name, source.Scope, destination.Name, destination.Scope, string(asset), "", &amtBi); err != nil { + return QueryBalanceError{WrappedError: err} } - - srcBalance := st.CachedBalances.fetchBalance(source, asset, "") - srcBalance.Sub(srcBalance, &amtBi) - - destBalance := st.CachedBalances.fetchBalance(destination, asset, "") - destBalance.Add(destBalance, &amtBi) - - st.Postings = append(st.Postings, newPosting(source, destination, new(big.Int).Set(&amtBi), string(asset), "")) + return nil } -func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) { - if monetary.Cmp(big.NewInt(0)) == 0 { - return +func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) InterpreterError { + // color == nil: drain the queue regardless of color, each posting keeping its + // source fund's own color. + var err error + if name.Name == KEPT_ADDR { + // kept funds are refunded to their sources, emitting no posting + err = st.rs.Send(nil, "", monetary, nil) + } else { + dest := name.Name + err = st.rs.Send(&dest, name.Scope, monetary, nil) } - - senders := st.fundsQueue.PullAnything(monetary) - - for _, sender := range senders { - posting := newPosting(sender.Account, name, sender.Amount, string(st.CurrentAsset), sender.Color) - - if name.Name == KEPT_ADDR { - // If funds are kept, give them back to senders - srcBalance := st.CachedBalances.fetchBalance(sender.Account, st.CurrentAsset, String(sender.Color)) - srcBalance.Add(srcBalance, posting.Amount) - - continue - } - - destBalance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, String(sender.Color)) - destBalance.Add(destBalance, posting.Amount) - - st.Postings = append(st.Postings, posting) + if err != nil { + return QueryBalanceError{WrappedError: err} } + return nil } func (st *programState) runStatement(statement parser.Statement) InterpreterError { @@ -354,29 +289,18 @@ func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) Int return err } - balance := st.CachedBalances.fetchBalance(account, asset, "") - - if amt == nil { - if balance.Sign() > 0 { - balance.Set(big.NewInt(0)) - } - } else { - // Do not allow negative saves - if amt.Cmp(big.NewInt(0)) == -1 { - return NegativeAmountErr{ - Range: saveStatement.SentValue.GetRange(), - Amount: MonetaryInt(*amt), - } - } - - // we decrease the balance by "amt" - balance.Sub(balance, amt) - // without going under 0 - if balance.Cmp(big.NewInt(0)) == -1 { - balance.Set(big.NewInt(0)) + // Do not allow negative saves + if amt != nil && amt.Cmp(big.NewInt(0)) == -1 { + return NegativeAmountErr{ + Range: saveStatement.SentValue.GetRange(), + Amount: MonetaryInt(*amt), } } + // amt == nil -> "save all"; otherwise reduce by amt, floored at 0 + if err := st.rs.Save(account.Name, account.Scope, string(asset), "", amt); err != nil { + return QueryBalanceError{WrappedError: err} + } return nil } @@ -388,6 +312,7 @@ func (st *programState) runSendStatement(statement parser.SendStatement) Interpr return err } st.CurrentAsset = asset + st.rs.SetCurrentAsset(string(asset)) sentAmt, err := st.takeAll(statement.Source) if err != nil { return err @@ -400,6 +325,7 @@ func (st *programState) runSendStatement(statement parser.SendStatement) Interpr return err } st.CurrentAsset = monetary.Asset + st.rs.SetCurrentAsset(string(monetary.Asset)) amtBi := big.Int(monetary.Amount) if amtBi.Sign() == -1 { @@ -444,12 +370,12 @@ func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overd return nil, err } - balance := s.CachedBalances.fetchBalance(account, s.CurrentAsset, color) - - // we sent balance+overdraft - sentAmt := CalculateMaxSafeWithdraw(balance, overdraft) - - s.pushSender(account, MonetaryInt(*sentAmt), color) + // PullUncapped queues balance+overdraft (== CalculateMaxSafeWithdraw), + // debiting the (account, currentAsset, color) balance. + sentAmt := new(big.Int) + if err := s.rs.PullUncapped(sentAmt, account.Name, account.Scope, overdraft, string(color)); err != nil { + return nil, QueryBalanceError{WrappedError: err} + } return sentAmt, nil } @@ -487,33 +413,40 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError return nil, err } - baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale() - acc, ok := s.CachedBalances[account] - if !ok { + baseAsset, assetScale := runtime.GetBaseAndScale(string(s.CurrentAsset)) + acc, balErr := s.rs.AccountBalances(account.Name, account.Scope) + if balErr != nil { + return nil, QueryBalanceError{WrappedError: balErr} + } + if len(acc) == 0 { return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range} } - sol, totSent := findScalingSolution( + sol, totSent := runtime.FindScalingSolution( nil, assetScale, - getAssets(acc, baseAsset), + runtime.GetAssets(acc, baseAsset), ) for _, convAmt := range sol { - s.forcePushPostingUncolored( + if err := s.forcePushPostingUncolored( account, scalingAccount, - MonetaryInt(*new(big.Int).Set(convAmt.amount)), - Asset(buildScaledAsset(baseAsset, convAmt.scale)), - ) + MonetaryInt(*new(big.Int).Set(convAmt.Amount)), + Asset(runtime.BuildScaledAsset(baseAsset, convAmt.Scale)), + ); err != nil { + return nil, err + } } - s.forcePushPostingUncolored( + if err := s.forcePushPostingUncolored( scalingAccount, account, MonetaryInt(*new(big.Int).Set(totSent)), s.CurrentAsset, - ) + ); err != nil { + return nil, err + } return s.takeAllFromAccount(source.Address, big.NewInt(0), nil) @@ -574,8 +507,6 @@ func (s *programState) tryTakingExact(source parser.Source, amount MonetaryInt) return nil } -var colorRe = regexp.MustCompile("^[A-Z]*$") - // PRE: overdraft >= 0 func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amount *big.Int, overdraft *big.Int, colorExpr parser.ValueExpr) (*big.Int, InterpreterError) { if colorExpr != nil { @@ -598,30 +529,17 @@ func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amo return nil, err } - var actuallySentAmt *big.Int - if overdraft == nil { - // unbounded overdraft: we send the required amount - actuallySentAmt = new(big.Int).Set(amount) - } else { - balance := s.CachedBalances.fetchBalance(account, s.CurrentAsset, color) - - // that's the amount we are allowed to send (balance + overdraft) - actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) + // Pull computes the available amount (min(max(0, balance+overdraft), amount) + // == CalculateSafeWithdraw; unbounded for world/overdraft==nil), debits the + // (account, currentAsset, color) balance, and queues the funds. The + // interpreter's overdraft convention (nil == unbounded) is exactly Pull's. + actuallySentAmt := new(big.Int) + if err := s.rs.Pull(actuallySentAmt, account.Name, account.Scope, amount, overdraft, string(color)); err != nil { + return nil, QueryBalanceError{WrappedError: err} } - s.pushSender(account, MonetaryInt(*actuallySentAmt), color) return actuallySentAmt, nil } -func (s *programState) cloneState() func() { - fqBackup := s.fundsQueue.Clone() - balancesBackup := s.CachedBalances.DeepClone() - - return func() { - s.fundsQueue = fqBackup - s.CachedBalances = balancesBackup - } -} - // Tries pulling up to "amount" and returns the actually pulled amt. // Doesn't fail (unless nested sources fail) func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*big.Int, InterpreterError) { @@ -649,34 +567,41 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return nil, err } - baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale() + baseAsset, assetScale := runtime.GetBaseAndScale(string(s.CurrentAsset)) - acc, ok := s.CachedBalances[account] - if !ok { + acc, balErr := s.rs.AccountBalances(account.Name, account.Scope) + if balErr != nil { + return nil, QueryBalanceError{WrappedError: balErr} + } + if len(acc) == 0 { return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range} } - sol, swappedAmt := findScalingSolution( + sol, swappedAmt := runtime.FindScalingSolution( amount, assetScale, - getAssets(acc, baseAsset), + runtime.GetAssets(acc, baseAsset), ) for _, pair := range sol { - s.forcePushPostingUncolored( + if err := s.forcePushPostingUncolored( account, scalingAccount, - NewMonetaryIntBig(pair.amount), - Asset(buildScaledAsset(baseAsset, pair.scale)), - ) + NewMonetaryIntBig(pair.Amount), + Asset(runtime.BuildScaledAsset(baseAsset, pair.Scale)), + ); err != nil { + return nil, err + } } - s.forcePushPostingUncolored( + if err := s.forcePushPostingUncolored( scalingAccount, account, NewMonetaryIntBig(swappedAmt), s.CurrentAsset, - ) + ); err != nil { + return nil, err + } return s.tryTakingFromAccount(source.Address, amount, big.NewInt(0), nil) @@ -712,10 +637,17 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi // empty oneof is parsing err leadingSources := source.Sources[0 : len(source.Sources)-1] - for _, source := range leadingSources { - // do not move this line below (as .tryTakingUpTo() will mutate the fundsQueue) - undo := s.cloneState() + // Open a region before the first tryTakingUpTo, which mutates the source + // queue. Exactly one is open at any point below: a branch that falls short + // closes its own with a rewind and immediately opens the next. + s.rs.MarkPush() + // every exit — a branch covering the amount, an error, or falling through to + // the last branch — returns from this function, so the deferred commit closes + // the open region exactly once on all of them. It cannot fail: there is always + // one unmatched push by here, and a nested oneof balances its own. + defer func() { _ = s.rs.MarkEnd(false) }() + for _, source := range leadingSources { sentAmt, err := s.tryTakingUpTo(source, amount) if err != nil { return nil, err @@ -726,8 +658,12 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return amount, nil } - // else, backtrack to remove this branch's sendings - undo() + // else undo this branch and reopen for the next one; after the rollback + // the fresh mark is identical to the one just closed + if err := s.rs.MarkEnd(true); err != nil { + return nil, QueryBalanceError{WrappedError: err} + } + s.rs.MarkPush() } return s.tryTakingUpTo(source.Sources[len(source.Sources)-1], amount) @@ -774,8 +710,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I if err != nil { return err } - s.pushReceiver(account, amount) - return nil + return s.pushReceiver(account, amount) case *parser.DestinationAllotment: var items []parser.AllotmentValue @@ -875,8 +810,7 @@ const KEPT_ADDR = "" func (s *programState) sendToKeptOrDest(keptOrDest parser.KeptOrDestination, amount *big.Int) InterpreterError { switch destinationTarget := keptOrDest.(type) { case *parser.DestinationKept: - s.pushReceiver(AccountAddress{Name: KEPT_ADDR}, amount) - return nil + return s.pushReceiver(AccountAddress{Name: KEPT_ADDR}, amount) case *parser.DestinationTo: return s.sendTo(destinationTarget.Destination, amount) @@ -969,73 +903,15 @@ func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, } } -var percentRegex = regexp.MustCompile(`^([0-9]+)(?:[.]([0-9]+))?[%]$`) -var fractionRegex = regexp.MustCompile(`^([0-9]+)\s?[/]\s?([0-9]+)$`) - -// slightly edited copy-paste from: -// https://github.com/formancehq/ledger/blob/b188d0c80eadaab5024d74edc967c7005e155f7c/internal/machine/portion.go#L57 - func ParsePortionSpecific(input string) (*big.Rat, InterpreterError) { - var res *big.Rat - var ok bool - - percentMatch := percentRegex.FindStringSubmatch(input) - if len(percentMatch) != 0 { - integral := percentMatch[1] - fractional := percentMatch[2] - res, ok = new(big.Rat).SetString(integral + "." + fractional) - if !ok { - return nil, BadPortionParsingErr{Reason: "invalid percent format", Source: input} - } - res.Mul(res, big.NewRat(1, 100)) - } else { - fractionMatch := fractionRegex.FindStringSubmatch(input) - if len(fractionMatch) != 0 { - numerator := fractionMatch[1] - denominator := fractionMatch[2] - res, ok = new(big.Rat).SetString(numerator + "/" + denominator) - if !ok { - return nil, BadPortionParsingErr{Reason: "invalid fractional format", Source: input} - } - } - } - if res == nil { - return nil, BadPortionParsingErr{Reason: "invalid format", Source: input} - } - - if res.Cmp(big.NewRat(0, 1)) == -1 || res.Cmp(big.NewRat(1, 1)) == 1 { - return nil, BadPortionParsingErr{Reason: "portion must be between 0% and 100% inclusive", Source: input} + res, err := runtime.ParsePortion(input) + if err != nil { + return nil, BadPortionParsingErr{Reason: err.Error(), Source: input} } return res, nil } -/* -PRE: ovedraft != nil, balance != nil -PRE: ovedraft >= 0 -POST: $out >= 0 -*/ -func CalculateMaxSafeWithdraw(balance *big.Int, overdraft *big.Int) *big.Int { - return utils.NonNeg( - new(big.Int).Add(balance, overdraft), - ) -} - -/* -PRE: ovedraft != nil, balance != nil -PRE: ovedraft >= 0 -PRE: requestedAmount >= 0 -POST: $out >= 0 -*/ -func CalculateSafeWithdraw( - balance *big.Int, - overdraft *big.Int, - requestedAmount *big.Int, -) *big.Int { - safe := CalculateMaxSafeWithdraw(balance, overdraft) - return utils.MinBigInt(safe, requestedAmount) -} - func PrettyPrintPostings(postings []Posting) string { // the optional columns (scopes, color) are dropped automatically when no // posting populates them diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index eee4f14f..42b187d8 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -436,6 +436,23 @@ func TestInvalidUnboundedWorldInSendAll(t *testing.T) { test(t, tc) } +// same as above, except world is only known at run time +func TestInvalidUnboundedWorldFromVarInSendAll(t *testing.T) { + tc := NewTestCase() + tc.compile(t, `vars { + account $src + } + send [USD/2 *] ( + source = $src + destination = @dest + )`) + tc.setVarsFromJSON(t, `{"src": "world"}`) + tc.expected = CaseResult{ + Error: interpreter.InvalidUnboundedInSendAll{Name: "world"}, + } + test(t, tc) +} + func TestInvalidUnboundedInSendAll(t *testing.T) { tc := NewTestCase() tc.compile(t, `send [USD/2 *] ( @@ -1131,135 +1148,6 @@ func TestColorRestrictBalanceWhenMissingFunds(t *testing.T) { testWithFeatureFlag(t, tc, flags.ExperimentalAssetColors) } -func TestSafeMaxWithdraft(t *testing.T) { - require.Equal(t, big.NewInt(0), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(0), - big.NewInt(0), - )) - - require.Equal(t, big.NewInt(200), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(100), - big.NewInt(100), - )) - - require.Equal(t, big.NewInt(105), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(100), - big.NewInt(5), - )) - - require.Equal(t, big.NewInt(0), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(-10), - big.NewInt(0), - )) - - require.Equal(t, big.NewInt(0), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(-10), - big.NewInt(5), - )) - - require.Equal(t, big.NewInt(0), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(-10), - big.NewInt(10), - )) - - require.Equal(t, big.NewInt(1), interpreter.CalculateMaxSafeWithdraw( - big.NewInt(-10), - big.NewInt(11), - )) -} - -// TODO this should be a fuzz test instead -func TestSafeWithdraft(t *testing.T) { - t.Run("with zero overdraft, only take what's available", func(t *testing.T) { - t.Run("balance > 0 allows you to take what's available", func(t *testing.T) { - require.Equal(t, big.NewInt(10), interpreter.CalculateSafeWithdraw( - big.NewInt(100), - big.NewInt(0), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(10), interpreter.CalculateSafeWithdraw( - big.NewInt(10), - big.NewInt(0), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(1), interpreter.CalculateSafeWithdraw( - big.NewInt(1), - big.NewInt(0), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(0), interpreter.CalculateSafeWithdraw( - big.NewInt(10), - big.NewInt(0), - big.NewInt(0), - )) - - // not enough balance: - require.Equal(t, big.NewInt(10), interpreter.CalculateSafeWithdraw( - big.NewInt(10), - big.NewInt(0), - big.NewInt(100), - )) - - }) - - t.Run("balance == 0 doesn't let you take anything", func(t *testing.T) { - require.Equal(t, big.NewInt(0), interpreter.CalculateSafeWithdraw( - big.NewInt(0), - big.NewInt(0), - big.NewInt(0), - )) - }) - - t.Run("balance < 0 doesn't let you take anything if there's no overdraft", func(t *testing.T) { - require.Equal(t, big.NewInt(0), interpreter.CalculateSafeWithdraw( - big.NewInt(-100), - big.NewInt(0), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(0), interpreter.CalculateSafeWithdraw( - big.NewInt(0), - big.NewInt(0), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(0), interpreter.CalculateSafeWithdraw( - big.NewInt(-1), - big.NewInt(0), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(0), interpreter.CalculateSafeWithdraw( - big.NewInt(-10), - big.NewInt(0), - big.NewInt(0), - )) - }) - }) - - t.Run("when overdraft is not zero, you can go over your balance", func(t *testing.T) { - t.Run("if we have enough balance>=requestedAmount, overdraft is ignored matter", func(t *testing.T) { - require.Equal(t, big.NewInt(10), interpreter.CalculateSafeWithdraw( - big.NewInt(100), - big.NewInt(100), - big.NewInt(10), - )) - require.Equal(t, big.NewInt(100), interpreter.CalculateSafeWithdraw( - big.NewInt(100), - big.NewInt(42), - big.NewInt(100), - )) - }) - - t.Run("if we have zero balance, overdraft allows us to withdraw", func(t *testing.T) { - require.Equal(t, big.NewInt(10), interpreter.CalculateSafeWithdraw( - big.NewInt(0), - big.NewInt(100), - big.NewInt(10), - )) - }) - - }) - -} - func TestInvalidScalingWorld(t *testing.T) { script := ` send [EUR/2 *] ( diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go index b77eb300..60f8a1ff 100644 --- a/internal/interpreter/resolve_dependencies.go +++ b/internal/interpreter/resolve_dependencies.go @@ -3,6 +3,7 @@ package interpreter import ( "context" "errors" + "math/big" "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/parser" @@ -78,7 +79,26 @@ func ResolveDependencies(ctx context.Context, store Store, vars map[string]strin // 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) + // + // dep resolution only needs the read to be recorded, not its value (a + // balance() yields a Monetary, which can't name an account), so getBalance + // just hits the recording store and returns zero — no funds engine involved. + getBalance := func(account AccountAddress, asset Asset) (*big.Int, InterpreterError) { + _, err := recording.GetBalances(ctx, BalanceQuery{ + {Account: account.Name, Asset: string(asset), Color: "", Scope: account.Scope}, + }) + if err != nil { + return nil, QueryBalanceError{WrappedError: err} + } + return new(big.Int), nil + } + env, err := newEvalEnv( + ctx, + recording, + nil, + getBalance, + program.Vars, vars, + ) if err != nil { return ResolvedDependencies{}, err } diff --git a/internal/interpreter/testdata/script-tests/dynamic-world-bounded-overdraft.num b/internal/interpreter/testdata/script-tests/dynamic-world-bounded-overdraft.num new file mode 100644 index 00000000..87beee6b --- /dev/null +++ b/internal/interpreter/testdata/script-tests/dynamic-world-bounded-overdraft.num @@ -0,0 +1,10 @@ +vars { + account $src +} +send [COIN 100] ( + source = { + $src allowing overdraft up to [COIN 5] + @world + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/dynamic-world-bounded-overdraft.num.specs.json b/internal/interpreter/testdata/script-tests/dynamic-world-bounded-overdraft.num.specs.json new file mode 100644 index 00000000..a2efae05 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/dynamic-world-bounded-overdraft.num.specs.json @@ -0,0 +1,38 @@ +{ + "testCases": [ + { + "it": "the overdraft bound applies to a regular account", + "variables": { + "src": "acc" + }, + "expect.postings": [ + { + "source": "acc", + "destination": "dest", + "amount": 5, + "asset": "COIN" + }, + { + "source": "world", + "destination": "dest", + "amount": 95, + "asset": "COIN" + } + ] + }, + { + "it": "but world discards it and covers the whole cap", + "variables": { + "src": "world" + }, + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/dynamic-world-source.num b/internal/interpreter/testdata/script-tests/dynamic-world-source.num new file mode 100644 index 00000000..836335b5 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/dynamic-world-source.num @@ -0,0 +1,7 @@ +vars { + account $src +} +send [COIN 100] ( + source = $src + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/dynamic-world-source.num.specs.json b/internal/interpreter/testdata/script-tests/dynamic-world-source.num.specs.json new file mode 100644 index 00000000..1e446669 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/dynamic-world-source.num.specs.json @@ -0,0 +1,25 @@ +{ + "testCases": [ + { + "it": "an account known only at run time is unbounded when it is world", + "variables": { + "src": "world" + }, + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "COIN" + } + ] + }, + { + "it": "but any other account is still bounded by its balance", + "variables": { + "src": "alice" + }, + "expect.error.missingFunds": true + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/account-interpolation/interpolated-world.num b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/interpolated-world.num new file mode 100644 index 00000000..ca9bd549 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/interpolated-world.num @@ -0,0 +1,7 @@ +vars { + string $name +} +send [COIN 100] ( + source = @$name + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/account-interpolation/interpolated-world.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/interpolated-world.num.specs.json new file mode 100644 index 00000000..d36fa29f --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/interpolated-world.num.specs.json @@ -0,0 +1,28 @@ +{ + "featureFlags": [ + "experimental-account-interpolation" + ], + "testCases": [ + { + "it": "unboundedness is decided on the interpolated name, not on the syntax", + "variables": { + "name": "world" + }, + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "COIN" + } + ] + }, + { + "it": "an interpolation that lands on another name stays bounded", + "variables": { + "name": "worldly" + }, + "expect.error.missingFunds": true + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-allotment-source.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-allotment-source.num new file mode 100644 index 00000000..f14b1b69 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-allotment-source.num @@ -0,0 +1,7 @@ +send [COIN 100] ( + source = { + 1/2 from @a \ "RED" + 1/2 from @b + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-allotment-source.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-allotment-source.num.specs.json new file mode 100644 index 00000000..ab6e52ca --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-allotment-source.num.specs.json @@ -0,0 +1,38 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "each allotment share keeps its own source color", + "balances": [ + { + "account": "a", + "asset": "COIN", + "color": "RED", + "amount": 50 + }, + { + "account": "b", + "asset": "COIN", + "amount": 50 + } + ], + "expect.postings": [ + { + "source": "a", + "destination": "dest", + "amount": 50, + "asset": "COIN", + "color": "RED" + }, + { + "source": "b", + "destination": "dest", + "amount": 50, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-bounded-overdraft.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-bounded-overdraft.num new file mode 100644 index 00000000..b382b271 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-bounded-overdraft.num @@ -0,0 +1,4 @@ +send [COIN 100] ( + source = @acc \ "RED" allowing overdraft up to [COIN 60] + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-bounded-overdraft.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-bounded-overdraft.num.specs.json new file mode 100644 index 00000000..f9e3f580 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-bounded-overdraft.num.specs.json @@ -0,0 +1,45 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "the overdraft applies to the colored balance only", + "balances": [ + { + "account": "acc", + "asset": "COIN", + "amount": 1000 + }, + { + "account": "acc", + "asset": "COIN", + "color": "RED", + "amount": 40 + } + ], + "expect.postings": [ + { + "source": "acc", + "destination": "dest", + "amount": 100, + "asset": "COIN", + "color": "RED" + } + ], + "expect.endBalances.include": [ + { + "account": "acc", + "asset": "COIN", + "color": "RED", + "amount": -60 + }, + { + "account": "acc", + "asset": "COIN", + "amount": 1000 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-capped-source.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-capped-source.num new file mode 100644 index 00000000..9602fbf9 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-capped-source.num @@ -0,0 +1,7 @@ +send [COIN 30] ( + source = { + max [COIN 10] from @src \ "RED" + @src + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-capped-source.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-capped-source.num.specs.json new file mode 100644 index 00000000..19ae8b6c --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-capped-source.num.specs.json @@ -0,0 +1,62 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "caps the colored pull, then falls back to the uncolored balance", + "balances": [ + { + "account": "src", + "asset": "COIN", + "amount": 100 + }, + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 10, + "asset": "COIN", + "color": "RED" + }, + { + "source": "src", + "destination": "dest", + "amount": 20, + "asset": "COIN" + } + ], + "expect.endBalances": [ + { + "account": "src", + "asset": "COIN", + "amount": 80 + }, + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 90 + }, + { + "account": "dest", + "asset": "COIN", + "amount": 20 + }, + { + "account": "dest", + "asset": "COIN", + "color": "RED", + "amount": 10 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-destination-split.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-destination-split.num new file mode 100644 index 00000000..0b389eb3 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-destination-split.num @@ -0,0 +1,7 @@ +send [COIN 100] ( + source = @src \ "RED" + destination = { + max [COIN 30] to @a + remaining to @b + } +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-destination-split.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-destination-split.num.specs.json new file mode 100644 index 00000000..a4a3eb31 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-destination-split.num.specs.json @@ -0,0 +1,34 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "every posting of the split keeps the source color", + "balances": [ + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "a", + "amount": 30, + "asset": "COIN", + "color": "RED" + }, + { + "source": "src", + "destination": "b", + "amount": 70, + "asset": "COIN", + "color": "RED" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-from-variable.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-from-variable.num new file mode 100644 index 00000000..1180d78f --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-from-variable.num @@ -0,0 +1,6 @@ +vars { string $color } + +send [COIN 30] ( + source = @src \ $color + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-from-variable.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-from-variable.num.specs.json new file mode 100644 index 00000000..320efbbe --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-from-variable.num.specs.json @@ -0,0 +1,49 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "balances": [ + { + "account": "src", + "asset": "COIN", + "amount": 40 + }, + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 50 + } + ], + "testCases": [ + { + "it": "pulls the colored balance", + "variables": { + "color": "RED" + }, + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 30, + "asset": "COIN", + "color": "RED" + } + ] + }, + { + "it": "an empty color pulls the uncolored balance", + "variables": { + "color": "" + }, + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 30, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-kept.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-kept.num new file mode 100644 index 00000000..a4dca210 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-kept.num @@ -0,0 +1,9 @@ +// `kept` emits no posting: the funds are refunded to the source, and must land +// back on the color they were pulled from. +send [COIN 100] ( + source = @src \ "RED" + destination = { + 30% to @dest + remaining kept + } +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-kept.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-kept.num.specs.json new file mode 100644 index 00000000..4631453f --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-kept.num.specs.json @@ -0,0 +1,41 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "refunds the kept funds to the source color", + "balances": [ + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 30, + "asset": "COIN", + "color": "RED" + } + ], + "expect.endBalances": [ + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 70 + }, + { + "account": "dest", + "asset": "COIN", + "color": "RED", + "amount": 30 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-oneof.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-oneof.num new file mode 100644 index 00000000..781b931e --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-oneof.num @@ -0,0 +1,9 @@ +// The first branch can only cover 10 of the 50 needed, so it is rolled back: +// the restore must repay the RED balance it debited, not the uncolored one. +send [COIN 50] ( + source = oneof { + @src \ "RED" + @src \ "BLUE" + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-oneof.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-oneof.num.specs.json new file mode 100644 index 00000000..6f33e94d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-oneof.num.specs.json @@ -0,0 +1,54 @@ +{ + "featureFlags": [ + "experimental-asset-colors", + "experimental-oneof" + ], + "testCases": [ + { + "it": "rolls the short branch back to its own color and commits the next one", + "balances": [ + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 10 + }, + { + "account": "src", + "asset": "COIN", + "color": "BLUE", + "amount": 50 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 50, + "asset": "COIN", + "color": "BLUE" + } + ], + "expect.endBalances": [ + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 10 + }, + { + "account": "src", + "asset": "COIN", + "color": "BLUE", + "amount": 0 + }, + { + "account": "dest", + "asset": "COIN", + "color": "BLUE", + "amount": 50 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-save.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-save.num new file mode 100644 index 00000000..fc440be9 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-save.num @@ -0,0 +1,8 @@ +// `save` is colorless: it only reduces the uncolored balance, so the colored +// send still sees its own funds. +save [COIN *] from @src + +send [COIN 50] ( + source = @src \ "RED" + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-save.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-save.num.specs.json new file mode 100644 index 00000000..46ad2961 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-save.num.specs.json @@ -0,0 +1,32 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "saving the uncolored balance leaves the colored one spendable", + "balances": [ + { + "account": "src", + "asset": "COIN", + "amount": 100 + }, + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 50 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 50, + "asset": "COIN", + "color": "RED" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-two-colors-to-split-destination.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-two-colors-to-split-destination.num new file mode 100644 index 00000000..bede6b02 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-two-colors-to-split-destination.num @@ -0,0 +1,12 @@ +// The destination has no color filter, so one drain consumes both colors: each +// half is funded by a different color and the postings say so. +send [COIN 100] ( + source = { + @src \ "RED" + @src \ "BLUE" + } + destination = { + 1/2 to @a + 1/2 to @b + } +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-two-colors-to-split-destination.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-two-colors-to-split-destination.num.specs.json new file mode 100644 index 00000000..b555fb43 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/color-two-colors-to-split-destination.num.specs.json @@ -0,0 +1,40 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "splits two colors across the two destinations", + "balances": [ + { + "account": "src", + "asset": "COIN", + "color": "RED", + "amount": 50 + }, + { + "account": "src", + "asset": "COIN", + "color": "BLUE", + "amount": 50 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "a", + "amount": 50, + "asset": "COIN", + "color": "RED" + }, + { + "source": "src", + "destination": "b", + "amount": 50, + "asset": "COIN", + "color": "BLUE" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/colored-world.num b/internal/interpreter/testdata/script-tests/experimental/asset-colors/colored-world.num new file mode 100644 index 00000000..9f47a8e8 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/colored-world.num @@ -0,0 +1,7 @@ +send [COIN 100] ( + source = { + max [COIN 30] from @world \ "RED" + @world \ "BLUE" + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-colors/colored-world.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-colors/colored-world.num.specs.json new file mode 100644 index 00000000..ca2c8d36 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/asset-colors/colored-world.num.specs.json @@ -0,0 +1,26 @@ +{ + "featureFlags": [ + "experimental-asset-colors" + ], + "testCases": [ + { + "it": "an unbounded pull keeps its color", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 30, + "asset": "COIN", + "color": "RED" + }, + { + "source": "world", + "destination": "dest", + "amount": 70, + "asset": "COIN", + "color": "BLUE" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-with-oneof.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-with-oneof.num.specs.json index 0043735e..9fa70e91 100644 --- a/internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-with-oneof.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/asset-scaling/scaling-with-oneof.num.specs.json @@ -53,6 +53,35 @@ "asset": "EUR/2" } ] + }, + { + "it": "discards the swap postings when the scaled branch falls short (acc1 holds a second scale, so unlike the cases above the swap is not a no-op)", + "balances": [ + { + "account": "acc1", + "asset": "EUR/2", + "amount": 1 + }, + { + "account": "acc1", + "asset": "EUR/3", + "amount": 10 + } + ], + "expect.postings": [ + { + "source": "acc1", + "destination": "dest", + "amount": 1, + "asset": "EUR/2" + }, + { + "source": "acc2", + "destination": "dest", + "amount": 99, + "asset": "EUR/2" + } + ] } ] } diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-destination-in-allotment.num b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-destination-in-allotment.num new file mode 100644 index 00000000..f1ccc00d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-destination-in-allotment.num @@ -0,0 +1,14 @@ + +// Destination oneof nested in an allotment: only half (50) is routed to the +// oneof, which is less than the full pull (100) — this exercises the capped send +// path. The first clause can hold 50, so @a gets it; @c gets the other half. +send [GEM 100] ( + source = @world + destination = { + 1/2 to oneof { + max [GEM 999] to @a + remaining to @b + } + 1/2 to @c + } +) diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-destination-in-allotment.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-destination-in-allotment.num.specs.json new file mode 100644 index 00000000..d62b9e5a --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-destination-in-allotment.num.specs.json @@ -0,0 +1,24 @@ +{ + "featureFlags": [ + "experimental-oneof" + ], + "testCases": [ + { + "it": "routes half to the oneof (capped) and half to @c", + "expect.postings": [ + { + "source": "world", + "destination": "a", + "amount": 50, + "asset": "GEM" + }, + { + "source": "world", + "destination": "c", + "amount": 50, + "asset": "GEM" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-partial-last-branch-in-inorder.num b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-partial-last-branch-in-inorder.num new file mode 100644 index 00000000..029e7a57 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-partial-last-branch-in-inorder.num @@ -0,0 +1,16 @@ + +// The oneof's LAST branch (@b) only partially covers the amount (30 of 100). +// A last branch's result must STAND (never be rolled back), and here it is then +// consumed by the enclosing inorder source: @b contributes 30, @c the rest (70). +// If the last branch were wrongly restored, @b's 30 would be undone and only @c +// would post. +send [GEM 100] ( + source = { + oneof { + @a + @b + } + @c + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-partial-last-branch-in-inorder.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-partial-last-branch-in-inorder.num.specs.json new file mode 100644 index 00000000..1fbffcde --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-partial-last-branch-in-inorder.num.specs.json @@ -0,0 +1,36 @@ +{ + "featureFlags": [ + "experimental-oneof" + ], + "testCases": [ + { + "it": "keeps the partially-satisfying last branch and takes the rest from the next inorder source", + "balances": [ + { + "account": "b", + "asset": "GEM", + "amount": 30 + }, + { + "account": "c", + "asset": "GEM", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "b", + "destination": "dest", + "amount": 30, + "asset": "GEM" + }, + { + "source": "c", + "destination": "dest", + "amount": 70, + "asset": "GEM" + } + ] + } + ] +} \ No newline at end of file diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-middle-branch.num b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-middle-branch.num new file mode 100644 index 00000000..7a9cf912 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-middle-branch.num @@ -0,0 +1,11 @@ + +// The middle branch commits: @a fails (empty) and is rolled back, @b covers the +// whole amount so we stop there, and @c is never pulled. +send [GEM 100] ( + source = oneof { + @a + @b + @c + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-middle-branch.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-middle-branch.num.specs.json new file mode 100644 index 00000000..cf398278 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-middle-branch.num.specs.json @@ -0,0 +1,30 @@ +{ + "featureFlags": [ + "experimental-oneof" + ], + "testCases": [ + { + "it": "commits the middle branch, rolling back the first and never pulling the third", + "balances": [ + { + "account": "b", + "asset": "GEM", + "amount": 100 + }, + { + "account": "c", + "asset": "GEM", + "amount": 200 + } + ], + "expect.postings": [ + { + "source": "b", + "destination": "dest", + "amount": 100, + "asset": "GEM" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-nested.num b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-nested.num new file mode 100644 index 00000000..7175c022 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-nested.num @@ -0,0 +1,16 @@ + +// Nested oneof: the outer's first branch is itself a oneof whose last branch (@b) +// partially pulls 30. The outer sees that's short of the full 100, rolls the +// inner pull back, and takes everything from @c. Only @c posts — which requires +// the outer restore to undo funds pulled through the inner oneof (independent +// snapshot marks). +send [GEM 100] ( + source = oneof { + oneof { + @a + @b + } + @c + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-nested.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-nested.num.specs.json new file mode 100644 index 00000000..7371e989 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/oneof/oneof-source-nested.num.specs.json @@ -0,0 +1,30 @@ +{ + "featureFlags": [ + "experimental-oneof" + ], + "testCases": [ + { + "it": "outer oneof rolls back an inner oneof's partial pull, then takes from the next branch", + "balances": [ + { + "account": "b", + "asset": "GEM", + "amount": 30 + }, + { + "account": "c", + "asset": "GEM", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "c", + "destination": "dest", + "amount": 100, + "asset": "GEM" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/movements-fold-same-pair.num b/internal/interpreter/testdata/script-tests/movements-fold-same-pair.num new file mode 100644 index 00000000..941b46fa --- /dev/null +++ b/internal/interpreter/testdata/script-tests/movements-fold-same-pair.num @@ -0,0 +1,8 @@ +// Two postings between the same pair fold into a single movement. +send [COIN 100] ( + source = @src + destination = { + max [COIN 30] to @dest + remaining to @dest + } +) diff --git a/internal/interpreter/testdata/script-tests/movements-fold-same-pair.num.specs.json b/internal/interpreter/testdata/script-tests/movements-fold-same-pair.num.specs.json new file mode 100644 index 00000000..e8a94fff --- /dev/null +++ b/internal/interpreter/testdata/script-tests/movements-fold-same-pair.num.specs.json @@ -0,0 +1,36 @@ +{ + "testCases": [ + { + "it": "-", + "balances": [ + { + "account": "src", + "asset": "COIN", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "dest", + "amount": 30, + "asset": "COIN" + }, + { + "source": "src", + "destination": "dest", + "amount": 70, + "asset": "COIN" + } + ], + "expect.movements": [ + { + "source": "src", + "destination": "dest", + "amount": 100, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/multiple-world-sources.num b/internal/interpreter/testdata/script-tests/multiple-world-sources.num new file mode 100644 index 00000000..ca4ec305 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/multiple-world-sources.num @@ -0,0 +1,18 @@ +send [COIN 10] ( + source = { + @world + @world + } + destination = @dest1 +) +send [COIN 20] ( + source = { + @a + @world + } + destination = @dest2 +) +send [COIN 30] ( + source = @world + destination = @dest3 +) diff --git a/internal/interpreter/testdata/script-tests/multiple-world-sources.num.specs.json b/internal/interpreter/testdata/script-tests/multiple-world-sources.num.specs.json new file mode 100644 index 00000000..feabbf0b --- /dev/null +++ b/internal/interpreter/testdata/script-tests/multiple-world-sources.num.specs.json @@ -0,0 +1,40 @@ +{ + "testCases": [ + { + "it": "several world sources across several sends", + "balances": [ + { + "account": "a", + "asset": "COIN", + "amount": 5 + } + ], + "expect.postings": [ + { + "source": "world", + "destination": "dest1", + "amount": 10, + "asset": "COIN" + }, + { + "source": "a", + "destination": "dest2", + "amount": 5, + "asset": "COIN" + }, + { + "source": "world", + "destination": "dest2", + "amount": 15, + "asset": "COIN" + }, + { + "source": "world", + "destination": "dest3", + "amount": 30, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/negative-amount.num b/internal/interpreter/testdata/script-tests/negative-amount.num new file mode 100644 index 00000000..95309c4f --- /dev/null +++ b/internal/interpreter/testdata/script-tests/negative-amount.num @@ -0,0 +1,4 @@ +send [COIN -1] ( + source = @world + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/negative-amount.num.specs.json b/internal/interpreter/testdata/script-tests/negative-amount.num.specs.json new file mode 100644 index 00000000..5b5b466d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/negative-amount.num.specs.json @@ -0,0 +1,8 @@ +{ + "testCases": [ + { + "it": "-", + "expect.error.negativeAmount": true + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/nested-world-inorder.num b/internal/interpreter/testdata/script-tests/nested-world-inorder.num new file mode 100644 index 00000000..617dd354 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/nested-world-inorder.num @@ -0,0 +1,14 @@ +send [COIN 100] ( + source = { + { + @a + { + @b + @world + } + @never_reached + } + @also_never_reached + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/nested-world-inorder.num.specs.json b/internal/interpreter/testdata/script-tests/nested-world-inorder.num.specs.json new file mode 100644 index 00000000..fffe3fe4 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/nested-world-inorder.num.specs.json @@ -0,0 +1,49 @@ +{ + "testCases": [ + { + "it": "world covers the rest from two levels deep, and nothing after it is reached", + "balances": [ + { + "account": "a", + "asset": "COIN", + "amount": 10 + }, + { + "account": "b", + "asset": "COIN", + "amount": 20 + }, + { + "account": "never_reached", + "asset": "COIN", + "amount": 1000 + }, + { + "account": "also_never_reached", + "asset": "COIN", + "amount": 1000 + } + ], + "expect.postings": [ + { + "source": "a", + "destination": "dest", + "amount": 10, + "asset": "COIN" + }, + { + "source": "b", + "destination": "dest", + "amount": 20, + "asset": "COIN" + }, + { + "source": "world", + "destination": "dest", + "amount": 70, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/send-all-after-unbounded-overdraft.num b/internal/interpreter/testdata/script-tests/send-all-after-unbounded-overdraft.num new file mode 100644 index 00000000..494e5676 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/send-all-after-unbounded-overdraft.num @@ -0,0 +1,10 @@ + +send [USD 1] ( + source = @src allowing unbounded overdraft + destination = @d1 +) + +send [USD *] ( + source = @src + destination = @d2 +) diff --git a/internal/interpreter/testdata/script-tests/send-all-after-unbounded-overdraft.num.specs.json b/internal/interpreter/testdata/script-tests/send-all-after-unbounded-overdraft.num.specs.json new file mode 100644 index 00000000..ecea6b9d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/send-all-after-unbounded-overdraft.num.specs.json @@ -0,0 +1,28 @@ +{ + "testCases": [ + { + "it": "send-all sees the starting balance after an unbounded-overdraft debit", + "balances": [ + { + "account": "src", + "asset": "USD", + "amount": 100 + } + ], + "expect.postings": [ + { + "source": "src", + "destination": "d1", + "amount": 1, + "asset": "USD" + }, + { + "source": "src", + "destination": "d2", + "amount": 99, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/world-from-meta.num b/internal/interpreter/testdata/script-tests/world-from-meta.num new file mode 100644 index 00000000..5f5c5b9d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-from-meta.num @@ -0,0 +1,8 @@ +vars { + account $config + account $src = meta($config, "source") +} +send [COIN 100] ( + source = $src + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/world-from-meta.num.specs.json b/internal/interpreter/testdata/script-tests/world-from-meta.num.specs.json new file mode 100644 index 00000000..26941d65 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-from-meta.num.specs.json @@ -0,0 +1,21 @@ +{ + "testCases": [ + { + "it": "an account read from metadata is unbounded when it is world", + "variables": { + "config": "config" + }, + "metadata": [ + { "account": "config", "key": "source", "value": "world" } + ], + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/world-in-capped-source.num b/internal/interpreter/testdata/script-tests/world-in-capped-source.num new file mode 100644 index 00000000..98372368 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-in-capped-source.num @@ -0,0 +1,8 @@ +send [COIN 100] ( + source = { + max [COIN 20] from @world + @a + @world + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/world-in-capped-source.num.specs.json b/internal/interpreter/testdata/script-tests/world-in-capped-source.num.specs.json new file mode 100644 index 00000000..a8d53a40 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-in-capped-source.num.specs.json @@ -0,0 +1,34 @@ +{ + "testCases": [ + { + "it": "a capped world source is clamped, and a later world source covers the rest", + "balances": [ + { + "account": "a", + "asset": "COIN", + "amount": 30 + } + ], + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 20, + "asset": "COIN" + }, + { + "source": "a", + "destination": "dest", + "amount": 30, + "asset": "COIN" + }, + { + "source": "world", + "destination": "dest", + "amount": 50, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/world-in-source-allotment.num b/internal/interpreter/testdata/script-tests/world-in-source-allotment.num new file mode 100644 index 00000000..ca355b8e --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-in-source-allotment.num @@ -0,0 +1,7 @@ +send [COIN 100] ( + source = { + 50% from @world + 50% from @a + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/world-in-source-allotment.num.specs.json b/internal/interpreter/testdata/script-tests/world-in-source-allotment.num.specs.json new file mode 100644 index 00000000..e8a06bba --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-in-source-allotment.num.specs.json @@ -0,0 +1,28 @@ +{ + "testCases": [ + { + "it": "world can be an allotment sub-source", + "balances": [ + { + "account": "a", + "asset": "COIN", + "amount": 50 + } + ], + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 50, + "asset": "COIN" + }, + { + "source": "a", + "destination": "dest", + "amount": 50, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/world-prefix-is-not-world.num b/internal/interpreter/testdata/script-tests/world-prefix-is-not-world.num new file mode 100644 index 00000000..7289f040 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-prefix-is-not-world.num @@ -0,0 +1,9 @@ +send [COIN 100] ( + source = { + @world:sub + @myworld + @worl + @world + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/world-prefix-is-not-world.num.specs.json b/internal/interpreter/testdata/script-tests/world-prefix-is-not-world.num.specs.json new file mode 100644 index 00000000..f6922caa --- /dev/null +++ b/internal/interpreter/testdata/script-tests/world-prefix-is-not-world.num.specs.json @@ -0,0 +1,15 @@ +{ + "testCases": [ + { + "it": "only the account named exactly world is unbounded", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "COIN" + } + ] + } + ] +} diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index e0c13507..95066f0f 100644 --- a/internal/interpreter/value.go +++ b/internal/interpreter/value.go @@ -4,11 +4,10 @@ import ( "encoding/json" "fmt" "math/big" - "strconv" - "strings" "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" ) type Value interface { @@ -42,14 +41,14 @@ func (Portion) value() {} func (Asset) value() {} func NewAccountAddress(src string) (AccountAddress, InterpreterError) { - if !checkAccountName(src) { + if !runtime.ValidateAccount(src) { return AccountAddress{}, InvalidAccountName{Name: src} } return AccountAddress{Name: src}, nil } func NewAsset(src string) (Asset, InterpreterError) { - if !checkAssetName(src) { + if !runtime.ValidateAsset(src) { return Asset(""), InvalidAsset{Name: src} } return Asset(src), nil @@ -74,14 +73,8 @@ const ( valueTypePortion = "portion" ) -// The per-shape tagged-JSON structs below are each shared by their type's -// MarshalJSON and by ParseTaggedValue, so the layout is defined once. -// -// scalar (string/number) -> { "type": ..., "value": "..." } -// asset -> { "type": "asset", "name": "COIN" } -// account -> { "type": "account", "name": "x", "scope": "s" } -// monetary -> { "type": "monetary", "asset": "COIN", "amount": "100" } -// portion -> { "type": "portion", "numerator": "1", "denominator": "2" } +// Each struct below is shared by its type's MarshalJSON and by ParseTaggedValue, +// so the layout is defined once. See the shapes above. type ( taggedScalar struct { Type string `json:"type"` @@ -132,10 +125,10 @@ func ParseTaggedValue(data []byte) (Value, error) { if err := json.Unmarshal(data, &v); err != nil { return nil, err } - if !checkAccountName(v.Name) { + if !runtime.ValidateAccount(v.Name) { return nil, fmt.Errorf("invalid account name: %q", v.Name) } - if !checkScopeName(v.Scope) { + if !runtime.ValidateScope(v.Scope) { return nil, fmt.Errorf("invalid account scope: %q", v.Scope) } return AccountAddress{Name: v.Name, Scope: v.Scope}, nil @@ -415,17 +408,3 @@ func (m MonetaryInt) Sub(other MonetaryInt) MonetaryInt { sum := new(big.Int).Sub(&bi, &otherBi) return MonetaryInt(*sum) } - -func (asset Asset) GetBaseAndScale() (string, int64) { - parts := strings.Split(string(asset), "/") - if len(parts) == 2 { - scale, err := strconv.ParseInt(parts[1], 10, 64) - if err == nil { - return parts[0], scale - } - // fallback if parsing fails - return parts[0], 0 - } - return string(asset), 0 - -} diff --git a/internal/ir/assemble.go b/internal/ir/assemble.go new file mode 100644 index 00000000..8f59c206 --- /dev/null +++ b/internal/ir/assemble.go @@ -0,0 +1,784 @@ +package ir + +import ( + "fmt" + "math" + "math/big" + + "github.com/formancehq/numscript/internal/vm" +) + +const maxReg = 0xFF + +type regPool struct { + indexByReg map[Reg]byte + next int +} + +func newRegPool() regPool { + return regPool{ + indexByReg: map[Reg]byte{}, + } +} + +type constPool[T any] struct { + indexByValue map[string]uint16 + items []T + toString func(T) string +} + +func newConstPool[T any](toString func(T) string) constPool[T] { + return constPool[T]{ + indexByValue: map[string]uint16{}, + toString: toString, + } +} + +func (p *constPool[T]) alloc(item T) (uint16, error) { + strValue := p.toString(item) + index, ok := p.indexByValue[strValue] + if !ok { + l := len(p.items) + if l > math.MaxUint16 { + return 0, fmt.Errorf("error: too many consts (overflowed the u16 len)") + } + index = uint16(l) + p.indexByValue[strValue] = index + p.items = append(p.items, item) + } + + return index, nil +} + +func (b *regPool) Index(r Reg) (byte, error) { + if idx, ok := b.indexByReg[r]; ok { + return idx, nil + } + if b.next >= maxReg { + return 0, fmt.Errorf("register bank overflow: more than %d registers in one bank (register allocation not implemented yet)", maxReg) + } + idx := byte(b.next) + b.next++ + b.indexByReg[r] = idx + return idx, nil +} + +type patch struct { + Label Label + index int + // delta is the forward jump offset, relative to the instruction following index + getInstruction func(delta uint16) vm.Instruction +} + +// assembler lowers IR instructions into a vm.Program. +type assembler struct { + instructions []vm.Instruction + + patches []patch + labels map[Label]uint16 + + // one register bank per VM register bank + ints regPool + strings regPool + Portions regPool + bools regPool + + intsPool constPool[big.Int] + stringsPool constPool[string] +} + +func Assemble(instrs []Instr) (vm.Program, error) { + a := &assembler{ + ints: newRegPool(), + strings: newRegPool(), + Portions: newRegPool(), + bools: newRegPool(), + + labels: map[Label]uint16{}, + + intsPool: newConstPool(func(i big.Int) string { + return i.String() + }), + stringsPool: newConstPool(func(s string) string { + return s + }), + } + for _, instr := range instrs { + if err := instr.assemble(a); err != nil { + return vm.Program{}, err + } + } + + // now we run the patches + for _, patch := range a.patches { + labelIndex, ok := a.labels[patch.Label] + if !ok { + return vm.Program{}, fmt.Errorf("missing label declaration of `%s`", string(patch.Label)) + } + + next := patch.index + 1 + if int(labelIndex) < next { + return vm.Program{}, fmt.Errorf("backward jump to label `%s`: jumps must go forward", string(patch.Label)) + } + + a.instructions[patch.index] = patch.getInstruction(uint16(int(labelIndex) - next)) + } + + return vm.Program{ + Instructions: a.instructions, + StringsPool: a.stringsPool.items, + IntsPool: a.intsPool.items, + + MaxRegString: byte(a.strings.next), + MaxRegPortion: byte(a.Portions.next), + MaxRegInt: byte(a.ints.next), + MaxRegBool: byte(a.bools.next), + }, nil +} + +func (as *assembler) intReg(r Reg) (byte, error) { return as.ints.Index(r) } +func (as *assembler) strReg(r Reg) (byte, error) { return as.strings.Index(r) } +func (as *assembler) portionReg(r Reg) (byte, error) { return as.Portions.Index(r) } +func (as *assembler) boolReg(r Reg) (byte, error) { return as.bools.Index(r) } + +func (as *assembler) optionalReg( + regPool func(*assembler, Reg) (byte, error), + Reg *Reg, +) (byte, error) { + if Reg == nil { + return maxReg, nil + } else { + reg_, err := regPool(as, *Reg) + if err != nil { + return 0, err + } + return reg_, nil + } + +} + +func (as *assembler) emit(op vm.Opcode, a, b, c byte) { + as.instructions = append(as.instructions, vm.Instruction{ + Opcode: byte(op), + A: a, + B: b, + C: c, + }) +} + +func (as *assembler) emitBC(op vm.Opcode, a byte, bc uint16) { + as.instructions = append(as.instructions, vm.NewBC(op, a, bc)) +} + +// regResolver maps a virtual register to a concrete bank index. op sigs hold +// these as method expressions ((*assembler).intReg, ...) so that a sig is a +// static description of an op, independent of any assembler instance. +type regResolver = func(*assembler, Reg) (byte, error) + +type unaryOpSig struct { + opcode vm.Opcode + dest regResolver + arg regResolver +} + +// copySig is the shape every bank copy shares: dest and src in the same bank. +func copySig(opcode vm.Opcode, bank regResolver) unaryOpSig { + return unaryOpSig{opcode: opcode, dest: bank, arg: bank} +} + +func (OpIntCopy) sig() unaryOpSig { return copySig(vm.Op_IntCopy, (*assembler).intReg) } +func (OpPortionCopy) sig() unaryOpSig { + return copySig(vm.Op_PortionCopy, (*assembler).portionReg) +} +func (OpStrCopy) sig() unaryOpSig { return copySig(vm.Op_StrCopy, (*assembler).strReg) } +func (OpBoolCopy) sig() unaryOpSig { return copySig(vm.Op_BoolCopy, (*assembler).boolReg) } +func (OpNegInt) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_NegInt, + dest: (*assembler).intReg, + arg: (*assembler).intReg, + } +} +func (OpIntToString) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_IntToString, + dest: (*assembler).strReg, + arg: (*assembler).intReg, + } +} +func (OpIsZero) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_IsZero, + dest: (*assembler).boolReg, + arg: (*assembler).intReg, + } +} +func (OpNot) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_Not, + dest: (*assembler).boolReg, + arg: (*assembler).boolReg, + } +} +func (OpPortionToString) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_PortionToString, + dest: (*assembler).strReg, + arg: (*assembler).portionReg, + } +} +func (OpIntToPortion) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_IntToPortion, + dest: (*assembler).portionReg, + arg: (*assembler).intReg, + } +} +func (OpPortionToInt) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_PortionToInt, + dest: (*assembler).intReg, + arg: (*assembler).portionReg, + } +} +func (i UnaryOp) assemble(a *assembler) error { + sig := i.Op.sig() + + dest, err := sig.dest(a, i.Dest) + if err != nil { + return err + } + arg, err := sig.arg(a, i.Arg) + if err != nil { + return err + } + + a.emit(sig.opcode, dest, arg, maxReg) + return nil +} + +type binaryOpSig struct { + opcode vm.Opcode + dest regResolver + left regResolver + right regResolver +} + +// comparisonSig is the shape every binary comparison shares: two operands of one +// bank, a bool dest. +func comparisonSig(opcode vm.Opcode, operand regResolver) binaryOpSig { + return binaryOpSig{ + opcode: opcode, + dest: (*assembler).boolReg, + left: operand, + right: operand, + } +} + +func (OpLtInt) sig() binaryOpSig { return comparisonSig(vm.Op_LtInt, (*assembler).intReg) } +func (OpEqInt) sig() binaryOpSig { return comparisonSig(vm.Op_EqInt, (*assembler).intReg) } +func (OpLtPortion) sig() binaryOpSig { + return comparisonSig(vm.Op_LtPortion, (*assembler).portionReg) +} +func (OpEqPortion) sig() binaryOpSig { + return comparisonSig(vm.Op_EqPortion, (*assembler).portionReg) +} +func (OpAddInt) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_AddInt, + dest: (*assembler).intReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (OpSubInt) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_SubInt, + dest: (*assembler).intReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (OpAddString) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_AddString, + dest: (*assembler).strReg, + left: (*assembler).strReg, + right: (*assembler).strReg, + } +} +func (OpStrEq) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_StrEq, + dest: (*assembler).boolReg, + left: (*assembler).strReg, + right: (*assembler).strReg, + } +} + +// portionArithSig is the shape of portion addition and subtraction: three +// portion operands. +func portionArithSig(opcode vm.Opcode) binaryOpSig { + return binaryOpSig{ + opcode: opcode, + dest: (*assembler).portionReg, + left: (*assembler).portionReg, + right: (*assembler).portionReg, + } +} + +func (OpAddPortion) sig() binaryOpSig { return portionArithSig(vm.Op_AddPortion) } +func (OpSubPortion) sig() binaryOpSig { return portionArithSig(vm.Op_SubPortion) } +func (OpMulPortion) sig() binaryOpSig { return portionArithSig(vm.Op_MulPortion) } +func (OpMakePortion) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_MkPortion, + dest: (*assembler).portionReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (OpMonetaryToString) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_MonetaryToString, + dest: (*assembler).strReg, + left: (*assembler).strReg, + right: (*assembler).intReg, + } +} + +func (i BinaryOp) assemble(a *assembler) error { + sig := i.Op.sig() + + dest, err := sig.dest(a, i.Dest) + if err != nil { + return err + } + left, err := sig.left(a, i.Left) + if err != nil { + return err + } + right, err := sig.right(a, i.Right) + if err != nil { + return err + } + + a.emit(sig.opcode, dest, left, right) + return nil +} + +func (i LoadInt) assemble(a *assembler) error { + dest, err := a.intReg(i.Dest) + if err != nil { + return err + } + + poolIndex, err := a.intsPool.alloc(i.Value) + if err != nil { + return err + } + + a.emitBC(vm.Op_LoadInt, dest, poolIndex) + return nil +} + +func (i LoadStr) assemble(a *assembler) error { + dest, err := a.strReg(i.Dest) + if err != nil { + return err + } + + poolIndex, err := a.stringsPool.alloc(i.Value) + if err != nil { + return err + } + + a.emitBC(vm.Op_LoadStr, dest, poolIndex) + return nil +} + +func (i ConstBool) assemble(a *assembler) error { + dest, err := a.boolReg(i.Dest) + if err != nil { + return err + } + + opcode := vm.Op_ConstFalse + if i.Value { + opcode = vm.Op_ConstTrue + } + + a.emit(opcode, dest, maxReg, maxReg) + return nil +} + +func (i CheckEnoughFunds) assemble(a *assembler) error { + got, err := a.intReg(i.Got) + if err != nil { + return err + } + + needed, err := a.intReg(i.Needed) + if err != nil { + return err + } + + a.emit(vm.Op_CheckEnoughFunds, got, needed, maxReg) + return nil +} + +func (i Save) assemble(a *assembler) error { + account, err := a.strReg(i.Account) + if err != nil { + return err + } + asset, err := a.strReg(i.Asset) + if err != nil { + return err + } + amount, err := a.optionalReg((*assembler).intReg, i.Amount) + if err != nil { + return err + } + a.emit(vm.Op_Save, account, asset, amount) + return nil +} + +func (i AssertLeftover) assemble(a *assembler) error { + portion, err := a.portionReg(i.Portion) + if err != nil { + return err + } + var exact byte + if i.Exact { + exact = 1 + } + a.emit(vm.Op_AssertLeftover, portion, exact, maxReg) + return nil +} + +func (i SetCurrentAsset) assemble(a *assembler) error { + assetReg, err := a.strReg(i.Asset) + if err != nil { + return err + } + + a.emit(vm.Op_SetCurrentAsset, assetReg, maxReg, maxReg) + return nil +} + +func (i PullAccount) assemble(a *assembler) error { + dest, err := a.intReg(i.Dest) + if err != nil { + return err + } + + account, err := a.strReg(i.Account) + if err != nil { + return err + } + + cap, err := a.optionalReg((*assembler).intReg, i.Cap) + if err != nil { + return err + } + + overdraft, err := a.optionalReg((*assembler).intReg, i.Overdraft) + if err != nil { + return err + } + + color, err := a.optionalReg((*assembler).strReg, i.Color) + if err != nil { + return err + } + + a.emit(vm.Op_PullAccount, dest, account, cap) + + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, // <- UNUSED + A: overdraft, // overdraft (int) + B: color, // color (str) + C: maxReg, // <- UNUSED + }) + + return nil +} + +func (i SendToAccount) assemble(a *assembler) error { + account, err := a.optionalReg((*assembler).strReg, i.Account) + if err != nil { + return err + } + + cap, err := a.optionalReg((*assembler).intReg, i.Cap) + if err != nil { + return err + } + + a.emit(vm.Op_SendToAccount, account, cap, maxReg) + return nil +} + +func (i AssertSameAsset) assemble(a *assembler) error { + left, err := a.strReg(i.Left) + if err != nil { + return err + } + right, err := a.strReg(i.Right) + if err != nil { + return err + } + + a.emit(vm.Op_AssertSameAsset, left, right, maxReg) + + return nil +} + +func (i AssertValidAccount) assemble(a *assembler) error { + account, err := a.strReg(i.Account) + if err != nil { + return err + } + + a.emit(vm.Op_AssertValidAccount, account, maxReg, maxReg) + + return nil +} + +func (i AssertValidColor) assemble(a *assembler) error { + color, err := a.strReg(i.Color) + if err != nil { + return err + } + + a.emit(vm.Op_AssertValidColor, color, maxReg, maxReg) + + return nil +} + +func (i AssertNonNegativeBalance) assemble(a *assembler) error { + balance, err := a.intReg(i.Balance) + if err != nil { + return err + } + account, err := a.strReg(i.Account) + if err != nil { + return err + } + + a.emit(vm.Op_AssertNonNegativeBalance, balance, account, maxReg) + + return nil +} + +func (i SetTxMeta) assemble(a *assembler) error { + key, err := a.strReg(i.Key) + if err != nil { + return err + } + value, err := a.strReg(i.Value) + if err != nil { + return err + } + + a.emit(vm.Op_SetTxMeta, key, value, maxReg) + + return nil +} + +func (a *assembler) emitMeta(opcode vm.Opcode, dest byte, account, key Reg) error { + acc, err := a.strReg(account) + if err != nil { + return err + } + k, err := a.strReg(key) + if err != nil { + return err + } + a.emit(opcode, dest, acc, k) + return nil +} + +func (MetaStr) assembleMeta(a *assembler, dest, account, key Reg) error { + d, err := a.strReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaStr, d, account, key) +} + +func (MetaInt) assembleMeta(a *assembler, dest, account, key Reg) error { + d, err := a.intReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaInt, d, account, key) +} + +func (MetaPortion) assembleMeta(a *assembler, dest, account, key Reg) error { + d, err := a.portionReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaPortion, d, account, key) +} + +func (i MetaVar) assemble(a *assembler) error { + return i.Typ.assembleMeta(a, i.Dest, i.Account, i.Key) +} + +// MetaMonetary needs four operands, so it spills the second destination into an +// ext word, like PullAccount. +func (i MetaMonetary) assemble(a *assembler) error { + destAsset, err := a.strReg(i.DestAsset) + if err != nil { + return err + } + destAmount, err := a.intReg(i.DestAmount) + if err != nil { + return err + } + if err := a.emitMeta(vm.Op_MetaMonetary, destAsset, i.Account, i.Key); err != nil { + return err + } + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, + A: destAmount, + B: maxReg, + C: maxReg, + }) + return nil +} + +func (i SetAccountMeta) assemble(a *assembler) error { + account, err := a.strReg(i.Account) + if err != nil { + return err + } + key, err := a.strReg(i.Key) + if err != nil { + return err + } + value, err := a.strReg(i.Value) + if err != nil { + return err + } + + a.emit(vm.Op_SetAccountMeta, account, key, value) + + return nil +} + +func (i FetchBalance) assemble(a *assembler) error { + dest, err := a.intReg(i.Dest) + if err != nil { + return err + } + account, err := a.strReg(i.Account) + if err != nil { + return err + } + asset, err := a.strReg(i.Asset) + if err != nil { + return err + } + + a.emit(vm.Op_Balance, dest, account, asset) + + return nil +} + +// assembleCondJmp emits either conditional jump: they differ only in the opcode. +func (a *assembler) assembleCondJmp(opcode vm.Opcode, cond Reg, target Label) error { + condReg, err := a.boolReg(cond) + if err != nil { + return err + } + + a.patches = append(a.patches, patch{ + Label: target, + index: len(a.instructions), + getInstruction: func(delta uint16) vm.Instruction { + return vm.NewBC(opcode, condReg, delta) + }, + }) + + // Emit dummy instruction + a.emit(0, 0, 0, 0) + + return nil +} + +func (i JmpIfFalse) assemble(a *assembler) error { + return a.assembleCondJmp(vm.Op_JmpIfFalse, i.Cond, i.Target) +} + +func (i JmpIfTrue) assemble(a *assembler) error { + return a.assembleCondJmp(vm.Op_JmpIfTrue, i.Cond, i.Target) +} + +func (i Jmp) assemble(a *assembler) error { + a.patches = append(a.patches, patch{ + Label: i.Target, + index: len(a.instructions), + getInstruction: func(delta uint16) vm.Instruction { + return vm.NewBC(vm.Op_Jmp, 0, delta) + }, + }) + + // Emit dummy instruction + a.emit(0, 0, 0, 0) + + return nil +} + +func (VarInt) assembleLoad(a *assembler, dest Reg, index uint16) error { + d, err := a.intReg(dest) + if err != nil { + return err + } + a.emitBC(vm.Op_LoadVarInt, d, index) + return nil +} + +func (VarStr) assembleLoad(a *assembler, dest Reg, index uint16) error { + d, err := a.strReg(dest) + if err != nil { + return err + } + a.emitBC(vm.Op_LoadVarStr, d, index) + return nil +} + +func (i LoadVar) assemble(a *assembler) error { + return i.Typ.assembleLoad(a, i.Dest, i.Index) +} + +func (i LabelMarker) assemble(a *assembler) error { + l := len(a.instructions) + if l > math.MaxUint16 { + return fmt.Errorf("too many labels: overflown max safe uint16") + } + + a.labels[i.Label] = uint16(l) + + return nil +} + +// the mark ops take no register, so there is nothing to allocate and no way to +// fail here + +func (i MarkPush) assemble(a *assembler) error { + a.emit(vm.Op_MarkPush, maxReg, maxReg, maxReg) + return nil +} + +func (i MarkEnd) assemble(a *assembler) error { + var rewind byte + if i.Rewind { + rewind = 1 + } + a.emit(vm.Op_MarkEnd, rewind, maxReg, maxReg) + return nil +} diff --git a/internal/ir/assemble_test.go b/internal/ir/assemble_test.go new file mode 100644 index 00000000..9dff6695 --- /dev/null +++ b/internal/ir/assemble_test.go @@ -0,0 +1,210 @@ +package ir + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +func TestAssemble_AddInt(t *testing.T) { + // Three distinct virtual int registers map to the first three int-bank + // indices in first-use order. + prog, err := Assemble([]Instr{ + BinaryOp{Op: OpAddInt{}, Dest: 10, Left: 20, Right: 30}, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + instrs := prog.Instructions + if len(instrs) != 1 { + t.Fatalf("got %d instructions, want 1", len(instrs)) + } + want := vm.Instruction{Opcode: byte(vm.Op_AddInt), A: 0, B: 1, C: 2} + if instrs[0] != want { + t.Errorf("got %+v, want %+v", instrs[0], want) + } +} + +func TestAssemble_AddInt_ReusesRegisterIndices(t *testing.T) { + // A virtual register reused across operands/instructions keeps the same + // bank index; new ones get fresh indices in first-use order. + prog, err := Assemble([]Instr{ + // Reg 7 -> 0, Reg 8 -> 1 ; dest==left==7 + BinaryOp{Op: OpAddInt{}, Dest: 7, Left: 7, Right: 8}, + // Reg 9 -> 2 ; reuses 7->0 and 8->1 + BinaryOp{Op: OpAddInt{}, Dest: 9, Left: 7, Right: 8}, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + got := prog.Instructions + want := []vm.Instruction{ + {Opcode: byte(vm.Op_AddInt), A: 0, B: 0, C: 1}, + {Opcode: byte(vm.Op_AddInt), A: 2, B: 0, C: 1}, + } + if len(got) != len(want) { + t.Fatalf("got %d instructions, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("instr[%d] = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestAssemble_Empty(t *testing.T) { + prog, err := Assemble(nil) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(prog.Instructions) != 0 { + t.Errorf("expected no instructions, got %d", len(prog.Instructions)) + } +} + +func TestAssemble_MaxRegPerBank(t *testing.T) { + prog, err := Assemble([]Instr{ + LoadStr{Dest: 0, Value: "USD/2"}, + LoadInt{Dest: 1, Value: *big.NewInt(10)}, + BinaryOp{Op: OpMakePortion{}, Left: 1, Right: 1, Dest: 3}, + }) + require.NoError(t, err) + + require.Equal(t, byte(1), prog.MaxRegString, "one str reg") + require.Equal(t, byte(1), prog.MaxRegInt, "one int reg") + require.Equal(t, byte(1), prog.MaxRegPortion, "one portion reg") + require.Equal(t, byte(0), prog.MaxRegBool, "no bool reg") +} + +// The bool value is in the opcode, so the two constants differ only there, and +// bool registers are indexed in their own bank. +func TestAssemble_ConstBool(t *testing.T) { + prog, err := Assemble([]Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + ConstBool{Dest: 1, Value: true}, + ConstBool{Dest: 2, Value: false}, + }) + require.NoError(t, err) + + require.Equal(t, []vm.Instruction{ + vm.NewBC(vm.Op_LoadInt, 0, 0), + {Opcode: byte(vm.Op_ConstTrue), A: 0, B: 0xFF, C: 0xFF}, + {Opcode: byte(vm.Op_ConstFalse), A: 1, B: 0xFF, C: 0xFF}, + }, prog.Instructions) + + require.Equal(t, byte(1), prog.MaxRegInt) + require.Equal(t, byte(2), prog.MaxRegBool) +} + +// 255 registers per bank, because 0xFF is the "operand unset" sentinel. +func TestAssemble_RegisterBankOverflow(t *testing.T) { + loadInts := func(n int) []Instr { + instrs := make([]Instr, n) + for i := range instrs { + instrs[i] = LoadInt{Dest: Reg(i), Value: *big.NewInt(int64(i))} + } + return instrs + } + + t.Run("255 registers fit", func(t *testing.T) { + prog, err := Assemble(loadInts(255)) + require.NoError(t, err) + require.Equal(t, byte(255), prog.MaxRegInt) + }) + + t.Run("256 do not", func(t *testing.T) { + _, err := Assemble(loadInts(256)) + require.ErrorContains(t, err, "register bank overflow") + }) + + t.Run("banks are counted separately", func(t *testing.T) { + instrs := loadInts(255) + for i := range 255 { + instrs = append(instrs, LoadStr{Dest: Reg(1000 + i), Value: "x"}) + } + _, err := Assemble(instrs) + require.NoError(t, err) + }) +} + +func TestAssemble_JmpDelta(t *testing.T) { + t.Run("delta counts the instructions skipped", func(t *testing.T) { + prog, err := Assemble([]Instr{ + ConstBool{Dest: 0, Value: true}, // 0 + JmpIfFalse{Cond: 0, Target: "end"}, // 1 + LoadInt{Dest: 1, Value: *big.NewInt(1)}, // 2 + LoadInt{Dest: 2, Value: *big.NewInt(2)}, // 3 + LabelMarker{Label: "end"}, // -> 4 + }) + require.NoError(t, err) + + require.Equal(t, vm.NewBC(vm.Op_JmpIfFalse, 0, 2), prog.Instructions[1]) + }) + + // the two conditional jumps differ only in the opcode + t.Run("jmp_if_true emits its own opcode", func(t *testing.T) { + prog, err := Assemble([]Instr{ + ConstBool{Dest: 0, Value: true}, + JmpIfTrue{Cond: 0, Target: "end"}, + LoadInt{Dest: 1, Value: *big.NewInt(1)}, + LabelMarker{Label: "end"}, + }) + require.NoError(t, err) + + require.Equal(t, vm.NewBC(vm.Op_JmpIfTrue, 0, 1), prog.Instructions[1]) + }) + + t.Run("jump to the immediately following instruction has delta 0", func(t *testing.T) { + prog, err := Assemble([]Instr{ + ConstBool{Dest: 0, Value: true}, + JmpIfFalse{Cond: 0, Target: "end"}, + LabelMarker{Label: "end"}, + LoadInt{Dest: 1, Value: *big.NewInt(1)}, + }) + require.NoError(t, err) + + require.Equal(t, vm.NewBC(vm.Op_JmpIfFalse, 0, 0), prog.Instructions[1]) + }) + + t.Run("backward jump is rejected", func(t *testing.T) { + _, err := Assemble([]Instr{ + LabelMarker{Label: "start"}, + ConstBool{Dest: 0, Value: true}, + JmpIfFalse{Cond: 0, Target: "start"}, + }) + require.ErrorContains(t, err, "backward jump") + }) + + t.Run("jump to itself is rejected", func(t *testing.T) { + _, err := Assemble([]Instr{ + ConstBool{Dest: 0, Value: true}, + LabelMarker{Label: "self"}, + JmpIfTrue{Cond: 0, Target: "self"}, + }) + require.ErrorContains(t, err, "backward jump") + }) + + t.Run("unconditional jmp patches its delta", func(t *testing.T) { + prog, err := Assemble([]Instr{ + Jmp{Target: "end"}, + LoadInt{Dest: 0, Value: *big.NewInt(0)}, + LabelMarker{Label: "end"}, + }) + require.NoError(t, err) + + // one instruction (the load) sits between the jump and the label + require.Equal(t, vm.NewBC(vm.Op_Jmp, 0, 1), prog.Instructions[0]) + }) + + t.Run("backward unconditional jmp is rejected", func(t *testing.T) { + _, err := Assemble([]Instr{ + LabelMarker{Label: "start"}, + Jmp{Target: "start"}, + }) + require.ErrorContains(t, err, "backward jump") + }) +} diff --git a/internal/ir/builder.go b/internal/ir/builder.go new file mode 100644 index 00000000..0955d69b --- /dev/null +++ b/internal/ir/builder.go @@ -0,0 +1,41 @@ +package ir + +import "fmt" + +// Builder accumulates an instruction stream, handing out the registers and +// labels it needs. +type Builder struct { + instrs []Instr + nextReg Reg + nextLabelID int +} + +func (b *Builder) FreshReg() Reg { + r := b.nextReg + b.nextReg++ + return r +} + +// FreshLabel suffixes prefix with a counter: "inorder_end" -> #inorder_end_0. +func (b *Builder) FreshLabel(prefix string) Label { + l := Label(fmt.Sprintf("%s_%d", prefix, b.nextLabelID)) + b.nextLabelID++ + return l +} + +func (b *Builder) Push(instr Instr) { + b.instrs = append(b.instrs, instr) +} + +// PushWithDest allocates the register the instruction writes to. Allocating here +// rather than up front keeps registers numbered in emission order, which is what +// makes a Dump of the result parse back to the same program. +func (b *Builder) PushWithDest(getInstr func(dest Reg) Instr) Reg { + dest := b.FreshReg() + b.Push(getInstr(dest)) + return dest +} + +func (b *Builder) Instrs() []Instr { + return b.instrs +} diff --git a/internal/ir/dump.go b/internal/ir/dump.go new file mode 100644 index 00000000..7daf35ea --- /dev/null +++ b/internal/ir/dump.go @@ -0,0 +1,217 @@ +package ir + +import ( + "fmt" + "strings" +) + +func (r Reg) String() string { return fmt.Sprintf("$r%d", uint(r)) } +func (l Label) String() string { return fmt.Sprintf("#%s", string(l)) } + +func (OpAddInt) String() string { return "add_int" } +func (OpSubInt) String() string { return "sub_int" } +func (OpAddString) String() string { return "add_string" } +func (OpStrEq) String() string { return "str_eq" } +func (OpLtInt) String() string { return "lt_int" } +func (OpEqInt) String() string { return "eq_int" } +func (OpLtPortion) String() string { return "lt_portion" } +func (OpEqPortion) String() string { return "eq_portion" } +func (OpAddPortion) String() string { return "add_portion" } +func (OpSubPortion) String() string { return "sub_portion" } +func (OpMulPortion) String() string { return "mul_portion" } +func (OpMakePortion) String() string { return "mk_portion" } +func (OpMonetaryToString) String() string { return "monetary_to_string" } + +func (OpIntCopy) String() string { return "int_copy" } +func (OpPortionCopy) String() string { return "portion_copy" } +func (OpStrCopy) String() string { return "str_copy" } +func (OpBoolCopy) String() string { return "bool_copy" } +func (OpNegInt) String() string { return "neg_int" } +func (OpIntToString) String() string { return "int_to_string" } +func (OpIsZero) String() string { return "is_zero" } +func (OpNot) String() string { return "not" } +func (OpPortionToString) String() string { return "portion_to_string" } +func (OpIntToPortion) String() string { return "int_to_portion" } +func (OpPortionToInt) String() string { return "portion_to_int" } + +func (i PullAccount) String() string { + opts := joinOpts( + optLabel("cap", i.Cap), + optLabel("overdraft", i.Overdraft), + optLabel("color", i.Color), + ) + s := fmt.Sprintf("%s = pull_account(account: %s", i.Dest, i.Account) + if opts != "" { + s += ", " + opts + } + return s + ")" +} + +func (i SendToAccount) String() string { + opts := joinOpts(optLabel("account", i.Account), optLabel("cap", i.Cap)) + return fmt.Sprintf("send_to_account(%s)", opts) +} + +func (i CheckEnoughFunds) String() string { + return fmt.Sprintf("check_enough_funds(%s, %s)", i.Got, i.Needed) +} + +func (i Save) String() string { + if i.Amount == nil { + return fmt.Sprintf("save(account: %s, asset: %s)", i.Account, i.Asset) + } + return fmt.Sprintf("save(account: %s, asset: %s, amount: %s)", i.Account, i.Asset, *i.Amount) +} + +func (i AssertLeftover) String() string { + if i.Exact { + return fmt.Sprintf("assert_leftover_exact(%s)", i.Portion) + } + return fmt.Sprintf("assert_leftover(%s)", i.Portion) +} + +func (i SetCurrentAsset) String() string { + return fmt.Sprintf("set_current_asset(%s)", i.Asset) +} + +func (i AssertSameAsset) String() string { + return fmt.Sprintf("assert_same_asset(%s, %s)", i.Left, i.Right) +} + +func (i AssertValidAccount) String() string { + return fmt.Sprintf("assert_valid_account(%s)", i.Account) +} + +func (i AssertValidColor) String() string { + return fmt.Sprintf("assert_valid_color(%s)", i.Color) +} + +func (i AssertNonNegativeBalance) String() string { + return fmt.Sprintf("assert_non_negative_balance(%s, %s)", i.Balance, i.Account) +} + +func (i SetTxMeta) String() string { + return fmt.Sprintf("set_tx_meta(%s, %s)", i.Key, i.Value) +} + +func (i SetAccountMeta) String() string { + return fmt.Sprintf("set_account_meta(%s, %s, %s)", i.Account, i.Key, i.Value) +} + +func (i MetaVar) String() string { + return fmt.Sprintf("%s = meta<%s>(%s, %s)", i.Dest, i.Typ, i.Account, i.Key) +} + +func (i MetaMonetary) String() string { + return fmt.Sprintf("[%s, %s] = meta_monetary(%s, %s)", i.DestAsset, i.DestAmount, i.Account, i.Key) +} + +func (MetaStr) String() string { return "str" } +func (MetaInt) String() string { return "int" } +func (MetaPortion) String() string { return "portion" } + +func (i FetchBalance) String() string { + return fmt.Sprintf("%s = balance(%s, %s)", i.Dest, i.Account, i.Asset) +} + +func (i LoadVar) String() string { + return fmt.Sprintf("%s = load_var<%s>(%d)", i.Dest, i.Typ, i.Index) +} + +func (VarInt) String() string { return "int" } +func (VarStr) String() string { return "str" } + +func (i JmpIfFalse) String() string { + return fmt.Sprintf("jmp_if_false(%s, %s)", i.Cond, i.Target) +} + +func (i JmpIfTrue) String() string { + return fmt.Sprintf("jmp_if_true(%s, %s)", i.Cond, i.Target) +} + +func (i Jmp) String() string { + return fmt.Sprintf("jmp(%s)", i.Target) +} + +func (i LoadInt) String() string { + return fmt.Sprintf("%s = %s", i.Dest, &i.Value) +} + +func (i LoadStr) String() string { + return fmt.Sprintf("%s = %q", i.Dest, i.Value) +} + +func (i ConstBool) String() string { + return fmt.Sprintf("%s = %t", i.Dest, i.Value) +} + +// infixAlias returns the infix spelling of an op, for the two that have one. +func infixAlias(k BinKind) (string, bool) { + switch k.(type) { + case OpAddInt: + return "+", true + case OpSubInt: + return "-", true + default: + return "", false + } +} + +func (i BinaryOp) String() string { + alias, hasAlias := infixAlias(i.Op) + switch { + case !hasAlias: + return fmt.Sprintf("%s = %s(%s, %s)", i.Dest, i.Op, i.Left, i.Right) + case i.Dest == i.Left: + // e.g. $acc += $Reg + return fmt.Sprintf("%s %s= %s", i.Dest, alias, i.Right) + default: + // e.g. $tot = $l + $r + return fmt.Sprintf("%s = %s %s %s", i.Dest, i.Left, alias, i.Right) + } +} + +func (i UnaryOp) String() string { + return fmt.Sprintf("%s = %s(%s)", i.Dest, i.Op, i.Arg) +} + +func (i LabelMarker) String() string { return i.Label.String() } + +func (i MarkPush) String() string { return "mark_push()" } + +func (i MarkEnd) String() string { + if i.Rewind { + return "mark_rewind()" + } + return "mark_commit()" +} + +// Dump renders a program: labels flush-left, instructions indented. +func Dump(code []Instr) string { + var b strings.Builder + for _, in := range code { + if _, ok := in.(LabelMarker); ok { + fmt.Fprintf(&b, "%s\n", in) + } else { + fmt.Fprintf(&b, " %s\n", in) + } + } + return b.String() +} + +func optLabel(name string, r *Reg) string { + if r == nil { + return "" + } + return fmt.Sprintf("%s: %s", name, *r) +} + +func joinOpts(parts ...string) string { + kept := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + kept = append(kept, p) + } + } + return strings.Join(kept, ", ") +} diff --git a/internal/ir/instr.go b/internal/ir/instr.go new file mode 100644 index 00000000..2777ad8b --- /dev/null +++ b/internal/ir/instr.go @@ -0,0 +1,298 @@ +// Package ir is the compiler's intermediate representation, and everything that +// operates on it: Parse and Dump convert to and from the textual format (see +// ir-textual-format.md), Typecheck checks register types, Assemble lowers to a +// vm.Program. The grammar's AST is internal, so callers only see instructions. +package ir + +import ( + "fmt" + "math/big" +) + +type Reg uint + +type Label string + +type BinKind interface { + fmt.Stringer + sig() binaryOpSig +} + +type ( + OpAddInt struct{} + OpSubInt struct{} + OpAddString struct{} + // The comparisons: only `<` and `==`, per type. `>`, `<=`, `>=` and `!=` are + // front-end normalisations over these plus OpNot — see the table in + // internal/vm/instruction.go. + OpLtInt struct{} + OpEqInt struct{} + OpLtPortion struct{} + OpEqPortion struct{} + // OpStrEq yields a bool: it is the one comparison that produces a value + // rather than trapping, and what the jumps branch on. + OpStrEq struct{} + OpAddPortion struct{} + OpSubPortion struct{} + OpMulPortion struct{} + OpMakePortion struct{} + // OpMonetaryToString takes the asset (str) and the amount (int) of a monetary + // and produces its "ASSET AMOUNT" form, the inverse of runtime.ParseMonetary. + OpMonetaryToString struct{} +) + +type UnKind interface { + fmt.Stringer + sig() unaryOpSig +} + +type ( + // One copy per register bank. A monetary has none: it is a (str, int) pair, so + // copying one is a str_copy plus an int_copy. + OpIntCopy struct{} + OpPortionCopy struct{} + OpStrCopy struct{} + OpBoolCopy struct{} + + OpNegInt struct{} + OpIntToString struct{} + // OpIsZero projects an int onto a bool, which is how a quantity reaches a + // jump: the jumps take a bool, so the projection has to be explicit. + OpIsZero struct{} + // OpNot is the only bool -> bool operation. + OpNot struct{} + OpPortionToString struct{} + // The two directions across the int/portion boundary. OpIntToPortion is + // exact; OpPortionToInt floors (big.Rat's denominator is always positive, so + // big.Int.Div is the floor). Together with OpMulPortion they are what an + // allotment share is made of. + OpIntToPortion struct{} + OpPortionToInt struct{} +) + +type VarType interface { + fmt.Stringer + assembleLoad(a *assembler, Dest Reg, Index uint16) error +} + +type ( + VarInt struct{} + VarStr struct{} +) + +type MetaType interface { + fmt.Stringer + assembleMeta(a *assembler, Dest, Account, Key Reg) error +} + +type ( + MetaStr struct{} + MetaInt struct{} + MetaPortion struct{} +) + +type ( + PullAccount struct { + Dest Reg // int: amount pulled + Account Reg // str + Cap, Overdraft, Color *Reg // int, int, str + } + SendToAccount struct { + Account, Cap *Reg // str, int + } + Save struct { + Account Reg // str + Asset Reg // str + Amount *Reg // int; nil = save all + } + CheckEnoughFunds struct{ Got, Needed Reg } // int + AssertLeftover struct { + Portion Reg // the allotment leftover (1 - sum of the given Portions) + Exact bool // no `remaining` clause: leftover must be exactly 0, else >= 0 + } + SetCurrentAsset struct{ Asset Reg } // str + AssertSameAsset struct{ Left, Right Reg } // str, str + AssertValidAccount struct{ Account Reg } // str + AssertValidColor struct{ Color Reg } // str + AssertNonNegativeBalance struct{ Balance, Account Reg } // int (the amount), str + SetTxMeta struct{ Key, Value Reg } // str, str + SetAccountMeta struct{ Account, Key, Value Reg } // str, str, str + MetaVar struct { + Dest Reg + Account, Key Reg // str, str + Typ MetaType + } + // MetaMonetary is meta: one store read yields both halves, so it is + // the only two-destination read and is not a MetaType. + MetaMonetary struct { + DestAsset Reg // str + DestAmount Reg // int + Account Reg // str + Key Reg // str + } + FetchBalance struct { + Dest Reg // int (the amount; the asset is the Asset operand) + Account, Asset Reg // str, str + } // reads the run-state (impure) + LoadVar struct { + Dest Reg + Typ VarType + Index uint16 + } + // The two conditional jumps differ only in which edge of the bool jumps, so + // either branch of a condition is one instruction and no negation is needed. + JmpIfFalse struct { + Cond Reg // bool + Target Label + } + JmpIfTrue struct { + Cond Reg // bool + Target Label + } + Jmp struct { + Target Label + } + LoadInt struct { + Dest Reg + Value big.Int + } + LoadStr struct { + Dest Reg + Value string + } + // ConstBool assembles to Op_ConstTrue or Op_ConstFalse: the value is in the + // opcode, so there is no pool entry. + ConstBool struct { + Dest Reg + Value bool + } + BinaryOp struct { + Op BinKind + Dest, Left, Right Reg + } + UnaryOp struct { + Op UnKind + Dest, Arg Reg + } + LabelMarker struct{ Label Label } + + // The mark ops, used for oneof backtracking. Neither takes a register: the mark + // is a source-queue depth on a LIFO the run-state owns, so no operand can name a + // depth it never marked. + // + // There is no "rewind but keep the mark" instruction: a retry is + // MarkEnd{Rewind: true} followed by a fresh MarkPush, which after the rollback + // yields a mark identical to the closed one. Mark depth is therefore a function + // of position in the instruction stream, so a verifier could decide statically + // that pushes and ends balance on every path, that no MarkEnd runs at depth 0, + // and that no SendToAccount, SetCurrentAsset or Save sits at depth > 0. No such + // pass exists yet — the VM enforces it at execution time — but keep emission + // verifiable: never emit a mark op on only one side of a branch. + MarkPush struct{} // opens a region at the current source-queue depth + // MarkEnd closes the innermost region. Rewind undoes what it did; otherwise the + // region's pulls and postings are committed. Dumps as mark_rewind / mark_commit. + MarkEnd struct{ Rewind bool } +) + +type Instr interface { + dests() []Reg // registers written + sources() []Reg // registers read + assemble(a *assembler) error +} + +func (i PullAccount) dests() []Reg { return []Reg{i.Dest} } +func (i PullAccount) sources() []Reg { return present(&i.Account, i.Cap, i.Overdraft, i.Color) } + +func (i SendToAccount) dests() []Reg { return nil } +func (i SendToAccount) sources() []Reg { return present(i.Account, i.Cap) } + +func (i CheckEnoughFunds) dests() []Reg { return nil } +func (i CheckEnoughFunds) sources() []Reg { return []Reg{i.Got, i.Needed} } + +func (i Save) dests() []Reg { return nil } +func (i Save) sources() []Reg { + regs := []Reg{i.Account, i.Asset} + if i.Amount != nil { + regs = append(regs, *i.Amount) + } + return regs +} + +func (i AssertLeftover) dests() []Reg { return nil } +func (i AssertLeftover) sources() []Reg { return []Reg{i.Portion} } + +func (i SetCurrentAsset) dests() []Reg { return nil } +func (i SetCurrentAsset) sources() []Reg { return []Reg{i.Asset} } + +func (i AssertSameAsset) dests() []Reg { return nil } +func (i AssertSameAsset) sources() []Reg { return []Reg{i.Left, i.Right} } + +func (i AssertValidAccount) dests() []Reg { return nil } +func (i AssertValidAccount) sources() []Reg { return []Reg{i.Account} } + +func (i AssertValidColor) dests() []Reg { return nil } +func (i AssertValidColor) sources() []Reg { return []Reg{i.Color} } + +func (i AssertNonNegativeBalance) dests() []Reg { return nil } +func (i AssertNonNegativeBalance) sources() []Reg { return []Reg{i.Balance, i.Account} } + +func (i SetTxMeta) dests() []Reg { return nil } +func (i SetTxMeta) sources() []Reg { return []Reg{i.Key, i.Value} } + +func (i SetAccountMeta) dests() []Reg { return nil } +func (i SetAccountMeta) sources() []Reg { return []Reg{i.Account, i.Key, i.Value} } + +func (i MetaVar) dests() []Reg { return []Reg{i.Dest} } +func (i MetaVar) sources() []Reg { return []Reg{i.Account, i.Key} } + +func (i MetaMonetary) dests() []Reg { return []Reg{i.DestAsset, i.DestAmount} } +func (i MetaMonetary) sources() []Reg { return []Reg{i.Account, i.Key} } + +func (i FetchBalance) dests() []Reg { return []Reg{i.Dest} } +func (i FetchBalance) sources() []Reg { return []Reg{i.Account, i.Asset} } + +func (i LoadVar) dests() []Reg { return []Reg{i.Dest} } +func (i LoadVar) sources() []Reg { return nil } + +func (i JmpIfFalse) dests() []Reg { return nil } +func (i JmpIfFalse) sources() []Reg { return []Reg{i.Cond} } + +func (i JmpIfTrue) dests() []Reg { return nil } +func (i JmpIfTrue) sources() []Reg { return []Reg{i.Cond} } + +func (i Jmp) dests() []Reg { return nil } +func (i Jmp) sources() []Reg { return nil } + +func (i LoadInt) dests() []Reg { return []Reg{i.Dest} } +func (i LoadInt) sources() []Reg { return nil } + +func (i LoadStr) dests() []Reg { return []Reg{i.Dest} } +func (i LoadStr) sources() []Reg { return nil } + +func (i ConstBool) dests() []Reg { return []Reg{i.Dest} } +func (i ConstBool) sources() []Reg { return nil } + +func (i BinaryOp) dests() []Reg { return []Reg{i.Dest} } +func (i BinaryOp) sources() []Reg { return []Reg{i.Left, i.Right} } + +func (i UnaryOp) dests() []Reg { return []Reg{i.Dest} } +func (i UnaryOp) sources() []Reg { return []Reg{i.Arg} } + +func (i LabelMarker) dests() []Reg { return nil } +func (i LabelMarker) sources() []Reg { return nil } + +func (i MarkPush) dests() []Reg { return nil } +func (i MarkPush) sources() []Reg { return nil } + +func (i MarkEnd) dests() []Reg { return nil } +func (i MarkEnd) sources() []Reg { return nil } + +func present(regs ...*Reg) []Reg { + out := make([]Reg, 0, len(regs)) + for _, r := range regs { + if r != nil { + out = append(out, *r) + } + } + return out +} diff --git a/internal/ir/internal/syntax/antlrParser/IR.interp b/internal/ir/internal/syntax/antlrParser/IR.interp new file mode 100644 index 00000000..a2b9c928 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/IR.interp @@ -0,0 +1,71 @@ +token literal names: +null +':' +null +null +null +null +null +null +null +null +null +'(' +')' +'[' +']' +',' +'=' +'+' +'-' +'+=' +'-=' +'<' +'>' +'_' + +token symbolic names: +null +null +WS +NEWLINE +TYPE_KEYWORD +BOOL +REG +LABEL +INT +STRING +IDENTIFIER +LPAREN +RPAREN +LBRACKET +RBRACKET +COMMA +EQ +PLUS +MINUS +PLUS_EQ +MINUS_EQ +LT +GT +UNDERSCORE + +rule names: +program +line +labelMarker +instruction +dest +regList +instrCall +instrName +typeName +args +arg +value +const_ +reg + + +atn: +[4, 1, 23, 126, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 1, 0, 5, 0, 30, 8, 0, 10, 0, 12, 0, 33, 9, 0, 1, 0, 1, 0, 1, 1, 1, 1, 3, 1, 39, 8, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 3, 3, 62, 8, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 70, 8, 4, 1, 5, 1, 5, 1, 5, 5, 5, 75, 8, 5, 10, 5, 12, 5, 78, 9, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 7, 3, 7, 90, 8, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 5, 9, 97, 8, 9, 10, 9, 12, 9, 100, 9, 9, 3, 9, 102, 8, 9, 1, 10, 1, 10, 1, 10, 1, 10, 3, 10, 108, 8, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 3, 11, 117, 8, 11, 1, 12, 1, 12, 1, 12, 3, 12, 122, 8, 12, 1, 13, 1, 13, 1, 13, 0, 0, 14, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 0, 2, 1, 0, 17, 18, 1, 0, 19, 20, 129, 0, 31, 1, 0, 0, 0, 2, 38, 1, 0, 0, 0, 4, 40, 1, 0, 0, 0, 6, 61, 1, 0, 0, 0, 8, 69, 1, 0, 0, 0, 10, 71, 1, 0, 0, 0, 12, 79, 1, 0, 0, 0, 14, 84, 1, 0, 0, 0, 16, 91, 1, 0, 0, 0, 18, 101, 1, 0, 0, 0, 20, 107, 1, 0, 0, 0, 22, 116, 1, 0, 0, 0, 24, 121, 1, 0, 0, 0, 26, 123, 1, 0, 0, 0, 28, 30, 3, 2, 1, 0, 29, 28, 1, 0, 0, 0, 30, 33, 1, 0, 0, 0, 31, 29, 1, 0, 0, 0, 31, 32, 1, 0, 0, 0, 32, 34, 1, 0, 0, 0, 33, 31, 1, 0, 0, 0, 34, 35, 5, 0, 0, 1, 35, 1, 1, 0, 0, 0, 36, 39, 3, 4, 2, 0, 37, 39, 3, 6, 3, 0, 38, 36, 1, 0, 0, 0, 38, 37, 1, 0, 0, 0, 39, 3, 1, 0, 0, 0, 40, 41, 5, 7, 0, 0, 41, 5, 1, 0, 0, 0, 42, 43, 3, 8, 4, 0, 43, 44, 5, 16, 0, 0, 44, 45, 3, 12, 6, 0, 45, 62, 1, 0, 0, 0, 46, 62, 3, 12, 6, 0, 47, 48, 3, 8, 4, 0, 48, 49, 5, 16, 0, 0, 49, 50, 3, 24, 12, 0, 50, 62, 1, 0, 0, 0, 51, 52, 3, 8, 4, 0, 52, 53, 5, 16, 0, 0, 53, 54, 3, 26, 13, 0, 54, 55, 7, 0, 0, 0, 55, 56, 3, 26, 13, 0, 56, 62, 1, 0, 0, 0, 57, 58, 3, 26, 13, 0, 58, 59, 7, 1, 0, 0, 59, 60, 3, 26, 13, 0, 60, 62, 1, 0, 0, 0, 61, 42, 1, 0, 0, 0, 61, 46, 1, 0, 0, 0, 61, 47, 1, 0, 0, 0, 61, 51, 1, 0, 0, 0, 61, 57, 1, 0, 0, 0, 62, 7, 1, 0, 0, 0, 63, 70, 3, 26, 13, 0, 64, 70, 5, 23, 0, 0, 65, 66, 5, 13, 0, 0, 66, 67, 3, 10, 5, 0, 67, 68, 5, 14, 0, 0, 68, 70, 1, 0, 0, 0, 69, 63, 1, 0, 0, 0, 69, 64, 1, 0, 0, 0, 69, 65, 1, 0, 0, 0, 70, 9, 1, 0, 0, 0, 71, 76, 3, 26, 13, 0, 72, 73, 5, 15, 0, 0, 73, 75, 3, 26, 13, 0, 74, 72, 1, 0, 0, 0, 75, 78, 1, 0, 0, 0, 76, 74, 1, 0, 0, 0, 76, 77, 1, 0, 0, 0, 77, 11, 1, 0, 0, 0, 78, 76, 1, 0, 0, 0, 79, 80, 3, 14, 7, 0, 80, 81, 5, 11, 0, 0, 81, 82, 3, 18, 9, 0, 82, 83, 5, 12, 0, 0, 83, 13, 1, 0, 0, 0, 84, 89, 5, 10, 0, 0, 85, 86, 5, 21, 0, 0, 86, 87, 3, 16, 8, 0, 87, 88, 5, 22, 0, 0, 88, 90, 1, 0, 0, 0, 89, 85, 1, 0, 0, 0, 89, 90, 1, 0, 0, 0, 90, 15, 1, 0, 0, 0, 91, 92, 5, 4, 0, 0, 92, 17, 1, 0, 0, 0, 93, 98, 3, 20, 10, 0, 94, 95, 5, 15, 0, 0, 95, 97, 3, 20, 10, 0, 96, 94, 1, 0, 0, 0, 97, 100, 1, 0, 0, 0, 98, 96, 1, 0, 0, 0, 98, 99, 1, 0, 0, 0, 99, 102, 1, 0, 0, 0, 100, 98, 1, 0, 0, 0, 101, 93, 1, 0, 0, 0, 101, 102, 1, 0, 0, 0, 102, 19, 1, 0, 0, 0, 103, 108, 3, 22, 11, 0, 104, 105, 5, 10, 0, 0, 105, 106, 5, 1, 0, 0, 106, 108, 3, 22, 11, 0, 107, 103, 1, 0, 0, 0, 107, 104, 1, 0, 0, 0, 108, 21, 1, 0, 0, 0, 109, 117, 3, 26, 13, 0, 110, 117, 5, 7, 0, 0, 111, 117, 5, 8, 0, 0, 112, 113, 5, 13, 0, 0, 113, 114, 3, 10, 5, 0, 114, 115, 5, 14, 0, 0, 115, 117, 1, 0, 0, 0, 116, 109, 1, 0, 0, 0, 116, 110, 1, 0, 0, 0, 116, 111, 1, 0, 0, 0, 116, 112, 1, 0, 0, 0, 117, 23, 1, 0, 0, 0, 118, 122, 5, 9, 0, 0, 119, 122, 5, 8, 0, 0, 120, 122, 5, 5, 0, 0, 121, 118, 1, 0, 0, 0, 121, 119, 1, 0, 0, 0, 121, 120, 1, 0, 0, 0, 122, 25, 1, 0, 0, 0, 123, 124, 5, 6, 0, 0, 124, 27, 1, 0, 0, 0, 11, 31, 38, 61, 69, 76, 89, 98, 101, 107, 116, 121] \ No newline at end of file diff --git a/internal/ir/internal/syntax/antlrParser/IR.tokens b/internal/ir/internal/syntax/antlrParser/IR.tokens new file mode 100644 index 00000000..4da26b21 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/IR.tokens @@ -0,0 +1,37 @@ +T__0=1 +WS=2 +NEWLINE=3 +TYPE_KEYWORD=4 +BOOL=5 +REG=6 +LABEL=7 +INT=8 +STRING=9 +IDENTIFIER=10 +LPAREN=11 +RPAREN=12 +LBRACKET=13 +RBRACKET=14 +COMMA=15 +EQ=16 +PLUS=17 +MINUS=18 +PLUS_EQ=19 +MINUS_EQ=20 +LT=21 +GT=22 +UNDERSCORE=23 +':'=1 +'('=11 +')'=12 +'['=13 +']'=14 +','=15 +'='=16 +'+'=17 +'-'=18 +'+='=19 +'-='=20 +'<'=21 +'>'=22 +'_'=23 diff --git a/internal/ir/internal/syntax/antlrParser/IRLexer.interp b/internal/ir/internal/syntax/antlrParser/IRLexer.interp new file mode 100644 index 00000000..165146b5 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/IRLexer.interp @@ -0,0 +1,86 @@ +token literal names: +null +':' +null +null +null +null +null +null +null +null +null +'(' +')' +'[' +']' +',' +'=' +'+' +'-' +'+=' +'-=' +'<' +'>' +'_' + +token symbolic names: +null +null +WS +NEWLINE +TYPE_KEYWORD +BOOL +REG +LABEL +INT +STRING +IDENTIFIER +LPAREN +RPAREN +LBRACKET +RBRACKET +COMMA +EQ +PLUS +MINUS +PLUS_EQ +MINUS_EQ +LT +GT +UNDERSCORE + +rule names: +T__0 +WS +NEWLINE +TYPE_KEYWORD +BOOL +REG +LABEL +INT +STRING +IDENTIFIER +LPAREN +RPAREN +LBRACKET +RBRACKET +COMMA +EQ +PLUS +MINUS +PLUS_EQ +MINUS_EQ +LT +GT +UNDERSCORE + +channel names: +DEFAULT_TOKEN_CHANNEL +HIDDEN + +mode names: +DEFAULT_MODE + +atn: +[4, 0, 23, 164, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 1, 0, 1, 0, 1, 1, 4, 1, 51, 8, 1, 11, 1, 12, 1, 52, 1, 1, 1, 1, 1, 2, 4, 2, 58, 8, 2, 11, 2, 12, 2, 59, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 3, 3, 85, 8, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 96, 8, 4, 1, 5, 1, 5, 1, 5, 5, 5, 101, 8, 5, 10, 5, 12, 5, 104, 9, 5, 1, 6, 1, 6, 1, 6, 5, 6, 109, 8, 6, 10, 6, 12, 6, 112, 9, 6, 1, 7, 4, 7, 115, 8, 7, 11, 7, 12, 7, 116, 1, 8, 1, 8, 1, 8, 1, 8, 5, 8, 123, 8, 8, 10, 8, 12, 8, 126, 9, 8, 1, 8, 1, 8, 1, 9, 1, 9, 5, 9, 132, 8, 9, 10, 9, 12, 9, 135, 9, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 12, 1, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, 15, 1, 15, 1, 16, 1, 16, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1, 20, 1, 20, 1, 21, 1, 21, 1, 22, 1, 22, 0, 0, 23, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37, 19, 39, 20, 41, 21, 43, 22, 45, 23, 1, 0, 8, 2, 0, 9, 9, 32, 32, 2, 0, 10, 10, 13, 13, 3, 0, 65, 90, 95, 95, 97, 122, 4, 0, 48, 57, 65, 90, 95, 95, 97, 122, 1, 0, 48, 57, 3, 0, 10, 10, 13, 13, 34, 34, 1, 0, 97, 122, 3, 0, 48, 57, 95, 95, 97, 122, 175, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 1, 47, 1, 0, 0, 0, 3, 50, 1, 0, 0, 0, 5, 57, 1, 0, 0, 0, 7, 84, 1, 0, 0, 0, 9, 95, 1, 0, 0, 0, 11, 97, 1, 0, 0, 0, 13, 105, 1, 0, 0, 0, 15, 114, 1, 0, 0, 0, 17, 118, 1, 0, 0, 0, 19, 129, 1, 0, 0, 0, 21, 136, 1, 0, 0, 0, 23, 138, 1, 0, 0, 0, 25, 140, 1, 0, 0, 0, 27, 142, 1, 0, 0, 0, 29, 144, 1, 0, 0, 0, 31, 146, 1, 0, 0, 0, 33, 148, 1, 0, 0, 0, 35, 150, 1, 0, 0, 0, 37, 152, 1, 0, 0, 0, 39, 155, 1, 0, 0, 0, 41, 158, 1, 0, 0, 0, 43, 160, 1, 0, 0, 0, 45, 162, 1, 0, 0, 0, 47, 48, 5, 58, 0, 0, 48, 2, 1, 0, 0, 0, 49, 51, 7, 0, 0, 0, 50, 49, 1, 0, 0, 0, 51, 52, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 52, 53, 1, 0, 0, 0, 53, 54, 1, 0, 0, 0, 54, 55, 6, 1, 0, 0, 55, 4, 1, 0, 0, 0, 56, 58, 7, 1, 0, 0, 57, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 57, 1, 0, 0, 0, 59, 60, 1, 0, 0, 0, 60, 61, 1, 0, 0, 0, 61, 62, 6, 2, 0, 0, 62, 6, 1, 0, 0, 0, 63, 64, 5, 105, 0, 0, 64, 65, 5, 110, 0, 0, 65, 85, 5, 116, 0, 0, 66, 67, 5, 115, 0, 0, 67, 68, 5, 116, 0, 0, 68, 85, 5, 114, 0, 0, 69, 70, 5, 112, 0, 0, 70, 71, 5, 111, 0, 0, 71, 72, 5, 114, 0, 0, 72, 73, 5, 116, 0, 0, 73, 74, 5, 105, 0, 0, 74, 75, 5, 111, 0, 0, 75, 85, 5, 110, 0, 0, 76, 77, 5, 109, 0, 0, 77, 78, 5, 111, 0, 0, 78, 79, 5, 110, 0, 0, 79, 80, 5, 101, 0, 0, 80, 81, 5, 116, 0, 0, 81, 82, 5, 97, 0, 0, 82, 83, 5, 114, 0, 0, 83, 85, 5, 121, 0, 0, 84, 63, 1, 0, 0, 0, 84, 66, 1, 0, 0, 0, 84, 69, 1, 0, 0, 0, 84, 76, 1, 0, 0, 0, 85, 8, 1, 0, 0, 0, 86, 87, 5, 116, 0, 0, 87, 88, 5, 114, 0, 0, 88, 89, 5, 117, 0, 0, 89, 96, 5, 101, 0, 0, 90, 91, 5, 102, 0, 0, 91, 92, 5, 97, 0, 0, 92, 93, 5, 108, 0, 0, 93, 94, 5, 115, 0, 0, 94, 96, 5, 101, 0, 0, 95, 86, 1, 0, 0, 0, 95, 90, 1, 0, 0, 0, 96, 10, 1, 0, 0, 0, 97, 98, 5, 36, 0, 0, 98, 102, 7, 2, 0, 0, 99, 101, 7, 3, 0, 0, 100, 99, 1, 0, 0, 0, 101, 104, 1, 0, 0, 0, 102, 100, 1, 0, 0, 0, 102, 103, 1, 0, 0, 0, 103, 12, 1, 0, 0, 0, 104, 102, 1, 0, 0, 0, 105, 106, 5, 35, 0, 0, 106, 110, 7, 2, 0, 0, 107, 109, 7, 3, 0, 0, 108, 107, 1, 0, 0, 0, 109, 112, 1, 0, 0, 0, 110, 108, 1, 0, 0, 0, 110, 111, 1, 0, 0, 0, 111, 14, 1, 0, 0, 0, 112, 110, 1, 0, 0, 0, 113, 115, 7, 4, 0, 0, 114, 113, 1, 0, 0, 0, 115, 116, 1, 0, 0, 0, 116, 114, 1, 0, 0, 0, 116, 117, 1, 0, 0, 0, 117, 16, 1, 0, 0, 0, 118, 124, 5, 34, 0, 0, 119, 120, 5, 92, 0, 0, 120, 123, 5, 34, 0, 0, 121, 123, 8, 5, 0, 0, 122, 119, 1, 0, 0, 0, 122, 121, 1, 0, 0, 0, 123, 126, 1, 0, 0, 0, 124, 122, 1, 0, 0, 0, 124, 125, 1, 0, 0, 0, 125, 127, 1, 0, 0, 0, 126, 124, 1, 0, 0, 0, 127, 128, 5, 34, 0, 0, 128, 18, 1, 0, 0, 0, 129, 133, 7, 6, 0, 0, 130, 132, 7, 7, 0, 0, 131, 130, 1, 0, 0, 0, 132, 135, 1, 0, 0, 0, 133, 131, 1, 0, 0, 0, 133, 134, 1, 0, 0, 0, 134, 20, 1, 0, 0, 0, 135, 133, 1, 0, 0, 0, 136, 137, 5, 40, 0, 0, 137, 22, 1, 0, 0, 0, 138, 139, 5, 41, 0, 0, 139, 24, 1, 0, 0, 0, 140, 141, 5, 91, 0, 0, 141, 26, 1, 0, 0, 0, 142, 143, 5, 93, 0, 0, 143, 28, 1, 0, 0, 0, 144, 145, 5, 44, 0, 0, 145, 30, 1, 0, 0, 0, 146, 147, 5, 61, 0, 0, 147, 32, 1, 0, 0, 0, 148, 149, 5, 43, 0, 0, 149, 34, 1, 0, 0, 0, 150, 151, 5, 45, 0, 0, 151, 36, 1, 0, 0, 0, 152, 153, 5, 43, 0, 0, 153, 154, 5, 61, 0, 0, 154, 38, 1, 0, 0, 0, 155, 156, 5, 45, 0, 0, 156, 157, 5, 61, 0, 0, 157, 40, 1, 0, 0, 0, 158, 159, 5, 60, 0, 0, 159, 42, 1, 0, 0, 0, 160, 161, 5, 62, 0, 0, 161, 44, 1, 0, 0, 0, 162, 163, 5, 95, 0, 0, 163, 46, 1, 0, 0, 0, 11, 0, 52, 59, 84, 95, 102, 110, 116, 122, 124, 133, 1, 6, 0, 0] \ No newline at end of file diff --git a/internal/ir/internal/syntax/antlrParser/IRLexer.tokens b/internal/ir/internal/syntax/antlrParser/IRLexer.tokens new file mode 100644 index 00000000..4da26b21 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/IRLexer.tokens @@ -0,0 +1,37 @@ +T__0=1 +WS=2 +NEWLINE=3 +TYPE_KEYWORD=4 +BOOL=5 +REG=6 +LABEL=7 +INT=8 +STRING=9 +IDENTIFIER=10 +LPAREN=11 +RPAREN=12 +LBRACKET=13 +RBRACKET=14 +COMMA=15 +EQ=16 +PLUS=17 +MINUS=18 +PLUS_EQ=19 +MINUS_EQ=20 +LT=21 +GT=22 +UNDERSCORE=23 +':'=1 +'('=11 +')'=12 +'['=13 +']'=14 +','=15 +'='=16 +'+'=17 +'-'=18 +'+='=19 +'-='=20 +'<'=21 +'>'=22 +'_'=23 diff --git a/internal/ir/internal/syntax/antlrParser/ir_base_listener.go b/internal/ir/internal/syntax/antlrParser/ir_base_listener.go new file mode 100644 index 00000000..7860932b --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/ir_base_listener.go @@ -0,0 +1,177 @@ +// Code generated from IR.g4 by ANTLR 4.13.2. DO NOT EDIT. + +package antlrParser // IR +import "github.com/antlr4-go/antlr/v4" + +// BaseIRListener is a complete listener for a parse tree produced by IRParser. +type BaseIRListener struct{} + +var _ IRListener = &BaseIRListener{} + +// VisitTerminal is called when a terminal node is visited. +func (s *BaseIRListener) VisitTerminal(node antlr.TerminalNode) {} + +// VisitErrorNode is called when an error node is visited. +func (s *BaseIRListener) VisitErrorNode(node antlr.ErrorNode) {} + +// EnterEveryRule is called when any rule is entered. +func (s *BaseIRListener) EnterEveryRule(ctx antlr.ParserRuleContext) {} + +// ExitEveryRule is called when any rule is exited. +func (s *BaseIRListener) ExitEveryRule(ctx antlr.ParserRuleContext) {} + +// EnterProgram is called when production program is entered. +func (s *BaseIRListener) EnterProgram(ctx *ProgramContext) {} + +// ExitProgram is called when production program is exited. +func (s *BaseIRListener) ExitProgram(ctx *ProgramContext) {} + +// EnterLine is called when production line is entered. +func (s *BaseIRListener) EnterLine(ctx *LineContext) {} + +// ExitLine is called when production line is exited. +func (s *BaseIRListener) ExitLine(ctx *LineContext) {} + +// EnterLabelMarker is called when production labelMarker is entered. +func (s *BaseIRListener) EnterLabelMarker(ctx *LabelMarkerContext) {} + +// ExitLabelMarker is called when production labelMarker is exited. +func (s *BaseIRListener) ExitLabelMarker(ctx *LabelMarkerContext) {} + +// EnterInstrWithDest is called when production instrWithDest is entered. +func (s *BaseIRListener) EnterInstrWithDest(ctx *InstrWithDestContext) {} + +// ExitInstrWithDest is called when production instrWithDest is exited. +func (s *BaseIRListener) ExitInstrWithDest(ctx *InstrWithDestContext) {} + +// EnterInstrNoDest is called when production instrNoDest is entered. +func (s *BaseIRListener) EnterInstrNoDest(ctx *InstrNoDestContext) {} + +// ExitInstrNoDest is called when production instrNoDest is exited. +func (s *BaseIRListener) ExitInstrNoDest(ctx *InstrNoDestContext) {} + +// EnterConstAssign is called when production constAssign is entered. +func (s *BaseIRListener) EnterConstAssign(ctx *ConstAssignContext) {} + +// ExitConstAssign is called when production constAssign is exited. +func (s *BaseIRListener) ExitConstAssign(ctx *ConstAssignContext) {} + +// EnterInfixInstr is called when production infixInstr is entered. +func (s *BaseIRListener) EnterInfixInstr(ctx *InfixInstrContext) {} + +// ExitInfixInstr is called when production infixInstr is exited. +func (s *BaseIRListener) ExitInfixInstr(ctx *InfixInstrContext) {} + +// EnterCompoundAssignInstr is called when production compoundAssignInstr is entered. +func (s *BaseIRListener) EnterCompoundAssignInstr(ctx *CompoundAssignInstrContext) {} + +// ExitCompoundAssignInstr is called when production compoundAssignInstr is exited. +func (s *BaseIRListener) ExitCompoundAssignInstr(ctx *CompoundAssignInstrContext) {} + +// EnterDestReg is called when production destReg is entered. +func (s *BaseIRListener) EnterDestReg(ctx *DestRegContext) {} + +// ExitDestReg is called when production destReg is exited. +func (s *BaseIRListener) ExitDestReg(ctx *DestRegContext) {} + +// EnterDestDiscard is called when production destDiscard is entered. +func (s *BaseIRListener) EnterDestDiscard(ctx *DestDiscardContext) {} + +// ExitDestDiscard is called when production destDiscard is exited. +func (s *BaseIRListener) ExitDestDiscard(ctx *DestDiscardContext) {} + +// EnterDestList is called when production destList is entered. +func (s *BaseIRListener) EnterDestList(ctx *DestListContext) {} + +// ExitDestList is called when production destList is exited. +func (s *BaseIRListener) ExitDestList(ctx *DestListContext) {} + +// EnterRegList is called when production regList is entered. +func (s *BaseIRListener) EnterRegList(ctx *RegListContext) {} + +// ExitRegList is called when production regList is exited. +func (s *BaseIRListener) ExitRegList(ctx *RegListContext) {} + +// EnterInstrCall is called when production instrCall is entered. +func (s *BaseIRListener) EnterInstrCall(ctx *InstrCallContext) {} + +// ExitInstrCall is called when production instrCall is exited. +func (s *BaseIRListener) ExitInstrCall(ctx *InstrCallContext) {} + +// EnterInstrName is called when production instrName is entered. +func (s *BaseIRListener) EnterInstrName(ctx *InstrNameContext) {} + +// ExitInstrName is called when production instrName is exited. +func (s *BaseIRListener) ExitInstrName(ctx *InstrNameContext) {} + +// EnterTypeName is called when production typeName is entered. +func (s *BaseIRListener) EnterTypeName(ctx *TypeNameContext) {} + +// ExitTypeName is called when production typeName is exited. +func (s *BaseIRListener) ExitTypeName(ctx *TypeNameContext) {} + +// EnterArgs is called when production args is entered. +func (s *BaseIRListener) EnterArgs(ctx *ArgsContext) {} + +// ExitArgs is called when production args is exited. +func (s *BaseIRListener) ExitArgs(ctx *ArgsContext) {} + +// EnterPositionalArg is called when production positionalArg is entered. +func (s *BaseIRListener) EnterPositionalArg(ctx *PositionalArgContext) {} + +// ExitPositionalArg is called when production positionalArg is exited. +func (s *BaseIRListener) ExitPositionalArg(ctx *PositionalArgContext) {} + +// EnterLabeledArg is called when production labeledArg is entered. +func (s *BaseIRListener) EnterLabeledArg(ctx *LabeledArgContext) {} + +// ExitLabeledArg is called when production labeledArg is exited. +func (s *BaseIRListener) ExitLabeledArg(ctx *LabeledArgContext) {} + +// EnterValReg is called when production valReg is entered. +func (s *BaseIRListener) EnterValReg(ctx *ValRegContext) {} + +// ExitValReg is called when production valReg is exited. +func (s *BaseIRListener) ExitValReg(ctx *ValRegContext) {} + +// EnterValLabel is called when production valLabel is entered. +func (s *BaseIRListener) EnterValLabel(ctx *ValLabelContext) {} + +// ExitValLabel is called when production valLabel is exited. +func (s *BaseIRListener) ExitValLabel(ctx *ValLabelContext) {} + +// EnterValInt is called when production valInt is entered. +func (s *BaseIRListener) EnterValInt(ctx *ValIntContext) {} + +// ExitValInt is called when production valInt is exited. +func (s *BaseIRListener) ExitValInt(ctx *ValIntContext) {} + +// EnterValRegList is called when production valRegList is entered. +func (s *BaseIRListener) EnterValRegList(ctx *ValRegListContext) {} + +// ExitValRegList is called when production valRegList is exited. +func (s *BaseIRListener) ExitValRegList(ctx *ValRegListContext) {} + +// EnterConstString is called when production constString is entered. +func (s *BaseIRListener) EnterConstString(ctx *ConstStringContext) {} + +// ExitConstString is called when production constString is exited. +func (s *BaseIRListener) ExitConstString(ctx *ConstStringContext) {} + +// EnterConstInt is called when production constInt is entered. +func (s *BaseIRListener) EnterConstInt(ctx *ConstIntContext) {} + +// ExitConstInt is called when production constInt is exited. +func (s *BaseIRListener) ExitConstInt(ctx *ConstIntContext) {} + +// EnterConstBool is called when production constBool is entered. +func (s *BaseIRListener) EnterConstBool(ctx *ConstBoolContext) {} + +// ExitConstBool is called when production constBool is exited. +func (s *BaseIRListener) ExitConstBool(ctx *ConstBoolContext) {} + +// EnterReg is called when production reg is entered. +func (s *BaseIRListener) EnterReg(ctx *RegContext) {} + +// ExitReg is called when production reg is exited. +func (s *BaseIRListener) ExitReg(ctx *RegContext) {} diff --git a/internal/ir/internal/syntax/antlrParser/ir_lexer.go b/internal/ir/internal/syntax/antlrParser/ir_lexer.go new file mode 100644 index 00000000..3a2f0f76 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/ir_lexer.go @@ -0,0 +1,197 @@ +// Code generated from IR.g4 by ANTLR 4.13.2. DO NOT EDIT. + +package antlrParser + +import ( + "fmt" + "github.com/antlr4-go/antlr/v4" + "sync" + "unicode" +) + +// Suppress unused import error +var _ = fmt.Printf +var _ = sync.Once{} +var _ = unicode.IsLetter + +type IRLexer struct { + *antlr.BaseLexer + channelNames []string + modeNames []string + // TODO: EOF string +} + +var IRLexerLexerStaticData struct { + once sync.Once + serializedATN []int32 + ChannelNames []string + ModeNames []string + LiteralNames []string + SymbolicNames []string + RuleNames []string + PredictionContextCache *antlr.PredictionContextCache + atn *antlr.ATN + decisionToDFA []*antlr.DFA +} + +func irlexerLexerInit() { + staticData := &IRLexerLexerStaticData + staticData.ChannelNames = []string{ + "DEFAULT_TOKEN_CHANNEL", "HIDDEN", + } + staticData.ModeNames = []string{ + "DEFAULT_MODE", + } + staticData.LiteralNames = []string{ + "", "':'", "", "", "", "", "", "", "", "", "", "'('", "')'", "'['", + "']'", "','", "'='", "'+'", "'-'", "'+='", "'-='", "'<'", "'>'", "'_'", + } + staticData.SymbolicNames = []string{ + "", "", "WS", "NEWLINE", "TYPE_KEYWORD", "BOOL", "REG", "LABEL", "INT", + "STRING", "IDENTIFIER", "LPAREN", "RPAREN", "LBRACKET", "RBRACKET", + "COMMA", "EQ", "PLUS", "MINUS", "PLUS_EQ", "MINUS_EQ", "LT", "GT", "UNDERSCORE", + } + staticData.RuleNames = []string{ + "T__0", "WS", "NEWLINE", "TYPE_KEYWORD", "BOOL", "REG", "LABEL", "INT", + "STRING", "IDENTIFIER", "LPAREN", "RPAREN", "LBRACKET", "RBRACKET", + "COMMA", "EQ", "PLUS", "MINUS", "PLUS_EQ", "MINUS_EQ", "LT", "GT", "UNDERSCORE", + } + staticData.PredictionContextCache = antlr.NewPredictionContextCache() + staticData.serializedATN = []int32{ + 4, 0, 23, 164, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, + 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, + 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, + 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, + 20, 2, 21, 7, 21, 2, 22, 7, 22, 1, 0, 1, 0, 1, 1, 4, 1, 51, 8, 1, 11, 1, + 12, 1, 52, 1, 1, 1, 1, 1, 2, 4, 2, 58, 8, 2, 11, 2, 12, 2, 59, 1, 2, 1, + 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, + 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 3, 3, 85, 8, 3, + 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 96, 8, 4, 1, + 5, 1, 5, 1, 5, 5, 5, 101, 8, 5, 10, 5, 12, 5, 104, 9, 5, 1, 6, 1, 6, 1, + 6, 5, 6, 109, 8, 6, 10, 6, 12, 6, 112, 9, 6, 1, 7, 4, 7, 115, 8, 7, 11, + 7, 12, 7, 116, 1, 8, 1, 8, 1, 8, 1, 8, 5, 8, 123, 8, 8, 10, 8, 12, 8, 126, + 9, 8, 1, 8, 1, 8, 1, 9, 1, 9, 5, 9, 132, 8, 9, 10, 9, 12, 9, 135, 9, 9, + 1, 10, 1, 10, 1, 11, 1, 11, 1, 12, 1, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, + 15, 1, 15, 1, 16, 1, 16, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, + 1, 19, 1, 20, 1, 20, 1, 21, 1, 21, 1, 22, 1, 22, 0, 0, 23, 1, 1, 3, 2, + 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, + 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37, 19, 39, 20, 41, 21, 43, + 22, 45, 23, 1, 0, 8, 2, 0, 9, 9, 32, 32, 2, 0, 10, 10, 13, 13, 3, 0, 65, + 90, 95, 95, 97, 122, 4, 0, 48, 57, 65, 90, 95, 95, 97, 122, 1, 0, 48, 57, + 3, 0, 10, 10, 13, 13, 34, 34, 1, 0, 97, 122, 3, 0, 48, 57, 95, 95, 97, + 122, 175, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, + 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, + 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, + 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, + 0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, + 0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, + 0, 0, 1, 47, 1, 0, 0, 0, 3, 50, 1, 0, 0, 0, 5, 57, 1, 0, 0, 0, 7, 84, 1, + 0, 0, 0, 9, 95, 1, 0, 0, 0, 11, 97, 1, 0, 0, 0, 13, 105, 1, 0, 0, 0, 15, + 114, 1, 0, 0, 0, 17, 118, 1, 0, 0, 0, 19, 129, 1, 0, 0, 0, 21, 136, 1, + 0, 0, 0, 23, 138, 1, 0, 0, 0, 25, 140, 1, 0, 0, 0, 27, 142, 1, 0, 0, 0, + 29, 144, 1, 0, 0, 0, 31, 146, 1, 0, 0, 0, 33, 148, 1, 0, 0, 0, 35, 150, + 1, 0, 0, 0, 37, 152, 1, 0, 0, 0, 39, 155, 1, 0, 0, 0, 41, 158, 1, 0, 0, + 0, 43, 160, 1, 0, 0, 0, 45, 162, 1, 0, 0, 0, 47, 48, 5, 58, 0, 0, 48, 2, + 1, 0, 0, 0, 49, 51, 7, 0, 0, 0, 50, 49, 1, 0, 0, 0, 51, 52, 1, 0, 0, 0, + 52, 50, 1, 0, 0, 0, 52, 53, 1, 0, 0, 0, 53, 54, 1, 0, 0, 0, 54, 55, 6, + 1, 0, 0, 55, 4, 1, 0, 0, 0, 56, 58, 7, 1, 0, 0, 57, 56, 1, 0, 0, 0, 58, + 59, 1, 0, 0, 0, 59, 57, 1, 0, 0, 0, 59, 60, 1, 0, 0, 0, 60, 61, 1, 0, 0, + 0, 61, 62, 6, 2, 0, 0, 62, 6, 1, 0, 0, 0, 63, 64, 5, 105, 0, 0, 64, 65, + 5, 110, 0, 0, 65, 85, 5, 116, 0, 0, 66, 67, 5, 115, 0, 0, 67, 68, 5, 116, + 0, 0, 68, 85, 5, 114, 0, 0, 69, 70, 5, 112, 0, 0, 70, 71, 5, 111, 0, 0, + 71, 72, 5, 114, 0, 0, 72, 73, 5, 116, 0, 0, 73, 74, 5, 105, 0, 0, 74, 75, + 5, 111, 0, 0, 75, 85, 5, 110, 0, 0, 76, 77, 5, 109, 0, 0, 77, 78, 5, 111, + 0, 0, 78, 79, 5, 110, 0, 0, 79, 80, 5, 101, 0, 0, 80, 81, 5, 116, 0, 0, + 81, 82, 5, 97, 0, 0, 82, 83, 5, 114, 0, 0, 83, 85, 5, 121, 0, 0, 84, 63, + 1, 0, 0, 0, 84, 66, 1, 0, 0, 0, 84, 69, 1, 0, 0, 0, 84, 76, 1, 0, 0, 0, + 85, 8, 1, 0, 0, 0, 86, 87, 5, 116, 0, 0, 87, 88, 5, 114, 0, 0, 88, 89, + 5, 117, 0, 0, 89, 96, 5, 101, 0, 0, 90, 91, 5, 102, 0, 0, 91, 92, 5, 97, + 0, 0, 92, 93, 5, 108, 0, 0, 93, 94, 5, 115, 0, 0, 94, 96, 5, 101, 0, 0, + 95, 86, 1, 0, 0, 0, 95, 90, 1, 0, 0, 0, 96, 10, 1, 0, 0, 0, 97, 98, 5, + 36, 0, 0, 98, 102, 7, 2, 0, 0, 99, 101, 7, 3, 0, 0, 100, 99, 1, 0, 0, 0, + 101, 104, 1, 0, 0, 0, 102, 100, 1, 0, 0, 0, 102, 103, 1, 0, 0, 0, 103, + 12, 1, 0, 0, 0, 104, 102, 1, 0, 0, 0, 105, 106, 5, 35, 0, 0, 106, 110, + 7, 2, 0, 0, 107, 109, 7, 3, 0, 0, 108, 107, 1, 0, 0, 0, 109, 112, 1, 0, + 0, 0, 110, 108, 1, 0, 0, 0, 110, 111, 1, 0, 0, 0, 111, 14, 1, 0, 0, 0, + 112, 110, 1, 0, 0, 0, 113, 115, 7, 4, 0, 0, 114, 113, 1, 0, 0, 0, 115, + 116, 1, 0, 0, 0, 116, 114, 1, 0, 0, 0, 116, 117, 1, 0, 0, 0, 117, 16, 1, + 0, 0, 0, 118, 124, 5, 34, 0, 0, 119, 120, 5, 92, 0, 0, 120, 123, 5, 34, + 0, 0, 121, 123, 8, 5, 0, 0, 122, 119, 1, 0, 0, 0, 122, 121, 1, 0, 0, 0, + 123, 126, 1, 0, 0, 0, 124, 122, 1, 0, 0, 0, 124, 125, 1, 0, 0, 0, 125, + 127, 1, 0, 0, 0, 126, 124, 1, 0, 0, 0, 127, 128, 5, 34, 0, 0, 128, 18, + 1, 0, 0, 0, 129, 133, 7, 6, 0, 0, 130, 132, 7, 7, 0, 0, 131, 130, 1, 0, + 0, 0, 132, 135, 1, 0, 0, 0, 133, 131, 1, 0, 0, 0, 133, 134, 1, 0, 0, 0, + 134, 20, 1, 0, 0, 0, 135, 133, 1, 0, 0, 0, 136, 137, 5, 40, 0, 0, 137, + 22, 1, 0, 0, 0, 138, 139, 5, 41, 0, 0, 139, 24, 1, 0, 0, 0, 140, 141, 5, + 91, 0, 0, 141, 26, 1, 0, 0, 0, 142, 143, 5, 93, 0, 0, 143, 28, 1, 0, 0, + 0, 144, 145, 5, 44, 0, 0, 145, 30, 1, 0, 0, 0, 146, 147, 5, 61, 0, 0, 147, + 32, 1, 0, 0, 0, 148, 149, 5, 43, 0, 0, 149, 34, 1, 0, 0, 0, 150, 151, 5, + 45, 0, 0, 151, 36, 1, 0, 0, 0, 152, 153, 5, 43, 0, 0, 153, 154, 5, 61, + 0, 0, 154, 38, 1, 0, 0, 0, 155, 156, 5, 45, 0, 0, 156, 157, 5, 61, 0, 0, + 157, 40, 1, 0, 0, 0, 158, 159, 5, 60, 0, 0, 159, 42, 1, 0, 0, 0, 160, 161, + 5, 62, 0, 0, 161, 44, 1, 0, 0, 0, 162, 163, 5, 95, 0, 0, 163, 46, 1, 0, + 0, 0, 11, 0, 52, 59, 84, 95, 102, 110, 116, 122, 124, 133, 1, 6, 0, 0, + } + deserializer := antlr.NewATNDeserializer(nil) + staticData.atn = deserializer.Deserialize(staticData.serializedATN) + atn := staticData.atn + staticData.decisionToDFA = make([]*antlr.DFA, len(atn.DecisionToState)) + decisionToDFA := staticData.decisionToDFA + for index, state := range atn.DecisionToState { + decisionToDFA[index] = antlr.NewDFA(state, index) + } +} + +// IRLexerInit initializes any static state used to implement IRLexer. By default the +// static state used to implement the lexer is lazily initialized during the first call to +// NewIRLexer(). You can call this function if you wish to initialize the static state ahead +// of time. +func IRLexerInit() { + staticData := &IRLexerLexerStaticData + staticData.once.Do(irlexerLexerInit) +} + +// NewIRLexer produces a new lexer instance for the optional input antlr.CharStream. +func NewIRLexer(input antlr.CharStream) *IRLexer { + IRLexerInit() + l := new(IRLexer) + l.BaseLexer = antlr.NewBaseLexer(input) + staticData := &IRLexerLexerStaticData + l.Interpreter = antlr.NewLexerATNSimulator(l, staticData.atn, staticData.decisionToDFA, staticData.PredictionContextCache) + l.channelNames = staticData.ChannelNames + l.modeNames = staticData.ModeNames + l.RuleNames = staticData.RuleNames + l.LiteralNames = staticData.LiteralNames + l.SymbolicNames = staticData.SymbolicNames + l.GrammarFileName = "IR.g4" + // TODO: l.EOF = antlr.TokenEOF + + return l +} + +// IRLexer tokens. +const ( + IRLexerT__0 = 1 + IRLexerWS = 2 + IRLexerNEWLINE = 3 + IRLexerTYPE_KEYWORD = 4 + IRLexerBOOL = 5 + IRLexerREG = 6 + IRLexerLABEL = 7 + IRLexerINT = 8 + IRLexerSTRING = 9 + IRLexerIDENTIFIER = 10 + IRLexerLPAREN = 11 + IRLexerRPAREN = 12 + IRLexerLBRACKET = 13 + IRLexerRBRACKET = 14 + IRLexerCOMMA = 15 + IRLexerEQ = 16 + IRLexerPLUS = 17 + IRLexerMINUS = 18 + IRLexerPLUS_EQ = 19 + IRLexerMINUS_EQ = 20 + IRLexerLT = 21 + IRLexerGT = 22 + IRLexerUNDERSCORE = 23 +) diff --git a/internal/ir/internal/syntax/antlrParser/ir_listener.go b/internal/ir/internal/syntax/antlrParser/ir_listener.go new file mode 100644 index 00000000..ce84a4f0 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/ir_listener.go @@ -0,0 +1,165 @@ +// Code generated from IR.g4 by ANTLR 4.13.2. DO NOT EDIT. + +package antlrParser // IR +import "github.com/antlr4-go/antlr/v4" + +// IRListener is a complete listener for a parse tree produced by IRParser. +type IRListener interface { + antlr.ParseTreeListener + + // EnterProgram is called when entering the program production. + EnterProgram(c *ProgramContext) + + // EnterLine is called when entering the line production. + EnterLine(c *LineContext) + + // EnterLabelMarker is called when entering the labelMarker production. + EnterLabelMarker(c *LabelMarkerContext) + + // EnterInstrWithDest is called when entering the instrWithDest production. + EnterInstrWithDest(c *InstrWithDestContext) + + // EnterInstrNoDest is called when entering the instrNoDest production. + EnterInstrNoDest(c *InstrNoDestContext) + + // EnterConstAssign is called when entering the constAssign production. + EnterConstAssign(c *ConstAssignContext) + + // EnterInfixInstr is called when entering the infixInstr production. + EnterInfixInstr(c *InfixInstrContext) + + // EnterCompoundAssignInstr is called when entering the compoundAssignInstr production. + EnterCompoundAssignInstr(c *CompoundAssignInstrContext) + + // EnterDestReg is called when entering the destReg production. + EnterDestReg(c *DestRegContext) + + // EnterDestDiscard is called when entering the destDiscard production. + EnterDestDiscard(c *DestDiscardContext) + + // EnterDestList is called when entering the destList production. + EnterDestList(c *DestListContext) + + // EnterRegList is called when entering the regList production. + EnterRegList(c *RegListContext) + + // EnterInstrCall is called when entering the instrCall production. + EnterInstrCall(c *InstrCallContext) + + // EnterInstrName is called when entering the instrName production. + EnterInstrName(c *InstrNameContext) + + // EnterTypeName is called when entering the typeName production. + EnterTypeName(c *TypeNameContext) + + // EnterArgs is called when entering the args production. + EnterArgs(c *ArgsContext) + + // EnterPositionalArg is called when entering the positionalArg production. + EnterPositionalArg(c *PositionalArgContext) + + // EnterLabeledArg is called when entering the labeledArg production. + EnterLabeledArg(c *LabeledArgContext) + + // EnterValReg is called when entering the valReg production. + EnterValReg(c *ValRegContext) + + // EnterValLabel is called when entering the valLabel production. + EnterValLabel(c *ValLabelContext) + + // EnterValInt is called when entering the valInt production. + EnterValInt(c *ValIntContext) + + // EnterValRegList is called when entering the valRegList production. + EnterValRegList(c *ValRegListContext) + + // EnterConstString is called when entering the constString production. + EnterConstString(c *ConstStringContext) + + // EnterConstInt is called when entering the constInt production. + EnterConstInt(c *ConstIntContext) + + // EnterConstBool is called when entering the constBool production. + EnterConstBool(c *ConstBoolContext) + + // EnterReg is called when entering the reg production. + EnterReg(c *RegContext) + + // ExitProgram is called when exiting the program production. + ExitProgram(c *ProgramContext) + + // ExitLine is called when exiting the line production. + ExitLine(c *LineContext) + + // ExitLabelMarker is called when exiting the labelMarker production. + ExitLabelMarker(c *LabelMarkerContext) + + // ExitInstrWithDest is called when exiting the instrWithDest production. + ExitInstrWithDest(c *InstrWithDestContext) + + // ExitInstrNoDest is called when exiting the instrNoDest production. + ExitInstrNoDest(c *InstrNoDestContext) + + // ExitConstAssign is called when exiting the constAssign production. + ExitConstAssign(c *ConstAssignContext) + + // ExitInfixInstr is called when exiting the infixInstr production. + ExitInfixInstr(c *InfixInstrContext) + + // ExitCompoundAssignInstr is called when exiting the compoundAssignInstr production. + ExitCompoundAssignInstr(c *CompoundAssignInstrContext) + + // ExitDestReg is called when exiting the destReg production. + ExitDestReg(c *DestRegContext) + + // ExitDestDiscard is called when exiting the destDiscard production. + ExitDestDiscard(c *DestDiscardContext) + + // ExitDestList is called when exiting the destList production. + ExitDestList(c *DestListContext) + + // ExitRegList is called when exiting the regList production. + ExitRegList(c *RegListContext) + + // ExitInstrCall is called when exiting the instrCall production. + ExitInstrCall(c *InstrCallContext) + + // ExitInstrName is called when exiting the instrName production. + ExitInstrName(c *InstrNameContext) + + // ExitTypeName is called when exiting the typeName production. + ExitTypeName(c *TypeNameContext) + + // ExitArgs is called when exiting the args production. + ExitArgs(c *ArgsContext) + + // ExitPositionalArg is called when exiting the positionalArg production. + ExitPositionalArg(c *PositionalArgContext) + + // ExitLabeledArg is called when exiting the labeledArg production. + ExitLabeledArg(c *LabeledArgContext) + + // ExitValReg is called when exiting the valReg production. + ExitValReg(c *ValRegContext) + + // ExitValLabel is called when exiting the valLabel production. + ExitValLabel(c *ValLabelContext) + + // ExitValInt is called when exiting the valInt production. + ExitValInt(c *ValIntContext) + + // ExitValRegList is called when exiting the valRegList production. + ExitValRegList(c *ValRegListContext) + + // ExitConstString is called when exiting the constString production. + ExitConstString(c *ConstStringContext) + + // ExitConstInt is called when exiting the constInt production. + ExitConstInt(c *ConstIntContext) + + // ExitConstBool is called when exiting the constBool production. + ExitConstBool(c *ConstBoolContext) + + // ExitReg is called when exiting the reg production. + ExitReg(c *RegContext) +} diff --git a/internal/ir/internal/syntax/antlrParser/ir_parser.go b/internal/ir/internal/syntax/antlrParser/ir_parser.go new file mode 100644 index 00000000..b13f8265 --- /dev/null +++ b/internal/ir/internal/syntax/antlrParser/ir_parser.go @@ -0,0 +1,3024 @@ +// Code generated from IR.g4 by ANTLR 4.13.2. DO NOT EDIT. + +package antlrParser // IR +import ( + "fmt" + "strconv" + "sync" + + "github.com/antlr4-go/antlr/v4" +) + +// Suppress unused import errors +var _ = fmt.Printf +var _ = strconv.Itoa +var _ = sync.Once{} + +type IRParser struct { + *antlr.BaseParser +} + +var IRParserStaticData struct { + once sync.Once + serializedATN []int32 + LiteralNames []string + SymbolicNames []string + RuleNames []string + PredictionContextCache *antlr.PredictionContextCache + atn *antlr.ATN + decisionToDFA []*antlr.DFA +} + +func irParserInit() { + staticData := &IRParserStaticData + staticData.LiteralNames = []string{ + "", "':'", "", "", "", "", "", "", "", "", "", "'('", "')'", "'['", + "']'", "','", "'='", "'+'", "'-'", "'+='", "'-='", "'<'", "'>'", "'_'", + } + staticData.SymbolicNames = []string{ + "", "", "WS", "NEWLINE", "TYPE_KEYWORD", "BOOL", "REG", "LABEL", "INT", + "STRING", "IDENTIFIER", "LPAREN", "RPAREN", "LBRACKET", "RBRACKET", + "COMMA", "EQ", "PLUS", "MINUS", "PLUS_EQ", "MINUS_EQ", "LT", "GT", "UNDERSCORE", + } + staticData.RuleNames = []string{ + "program", "line", "labelMarker", "instruction", "dest", "regList", + "instrCall", "instrName", "typeName", "args", "arg", "value", "const_", + "reg", + } + staticData.PredictionContextCache = antlr.NewPredictionContextCache() + staticData.serializedATN = []int32{ + 4, 1, 23, 126, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, + 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, + 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 1, 0, 5, 0, 30, 8, 0, 10, + 0, 12, 0, 33, 9, 0, 1, 0, 1, 0, 1, 1, 1, 1, 3, 1, 39, 8, 1, 1, 2, 1, 2, + 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, + 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 3, 3, 62, 8, 3, 1, 4, 1, 4, 1, + 4, 1, 4, 1, 4, 1, 4, 3, 4, 70, 8, 4, 1, 5, 1, 5, 1, 5, 5, 5, 75, 8, 5, + 10, 5, 12, 5, 78, 9, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, + 1, 7, 1, 7, 3, 7, 90, 8, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 5, 9, 97, 8, + 9, 10, 9, 12, 9, 100, 9, 9, 3, 9, 102, 8, 9, 1, 10, 1, 10, 1, 10, 1, 10, + 3, 10, 108, 8, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 3, + 11, 117, 8, 11, 1, 12, 1, 12, 1, 12, 3, 12, 122, 8, 12, 1, 13, 1, 13, 1, + 13, 0, 0, 14, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 0, 2, + 1, 0, 17, 18, 1, 0, 19, 20, 129, 0, 31, 1, 0, 0, 0, 2, 38, 1, 0, 0, 0, + 4, 40, 1, 0, 0, 0, 6, 61, 1, 0, 0, 0, 8, 69, 1, 0, 0, 0, 10, 71, 1, 0, + 0, 0, 12, 79, 1, 0, 0, 0, 14, 84, 1, 0, 0, 0, 16, 91, 1, 0, 0, 0, 18, 101, + 1, 0, 0, 0, 20, 107, 1, 0, 0, 0, 22, 116, 1, 0, 0, 0, 24, 121, 1, 0, 0, + 0, 26, 123, 1, 0, 0, 0, 28, 30, 3, 2, 1, 0, 29, 28, 1, 0, 0, 0, 30, 33, + 1, 0, 0, 0, 31, 29, 1, 0, 0, 0, 31, 32, 1, 0, 0, 0, 32, 34, 1, 0, 0, 0, + 33, 31, 1, 0, 0, 0, 34, 35, 5, 0, 0, 1, 35, 1, 1, 0, 0, 0, 36, 39, 3, 4, + 2, 0, 37, 39, 3, 6, 3, 0, 38, 36, 1, 0, 0, 0, 38, 37, 1, 0, 0, 0, 39, 3, + 1, 0, 0, 0, 40, 41, 5, 7, 0, 0, 41, 5, 1, 0, 0, 0, 42, 43, 3, 8, 4, 0, + 43, 44, 5, 16, 0, 0, 44, 45, 3, 12, 6, 0, 45, 62, 1, 0, 0, 0, 46, 62, 3, + 12, 6, 0, 47, 48, 3, 8, 4, 0, 48, 49, 5, 16, 0, 0, 49, 50, 3, 24, 12, 0, + 50, 62, 1, 0, 0, 0, 51, 52, 3, 8, 4, 0, 52, 53, 5, 16, 0, 0, 53, 54, 3, + 26, 13, 0, 54, 55, 7, 0, 0, 0, 55, 56, 3, 26, 13, 0, 56, 62, 1, 0, 0, 0, + 57, 58, 3, 26, 13, 0, 58, 59, 7, 1, 0, 0, 59, 60, 3, 26, 13, 0, 60, 62, + 1, 0, 0, 0, 61, 42, 1, 0, 0, 0, 61, 46, 1, 0, 0, 0, 61, 47, 1, 0, 0, 0, + 61, 51, 1, 0, 0, 0, 61, 57, 1, 0, 0, 0, 62, 7, 1, 0, 0, 0, 63, 70, 3, 26, + 13, 0, 64, 70, 5, 23, 0, 0, 65, 66, 5, 13, 0, 0, 66, 67, 3, 10, 5, 0, 67, + 68, 5, 14, 0, 0, 68, 70, 1, 0, 0, 0, 69, 63, 1, 0, 0, 0, 69, 64, 1, 0, + 0, 0, 69, 65, 1, 0, 0, 0, 70, 9, 1, 0, 0, 0, 71, 76, 3, 26, 13, 0, 72, + 73, 5, 15, 0, 0, 73, 75, 3, 26, 13, 0, 74, 72, 1, 0, 0, 0, 75, 78, 1, 0, + 0, 0, 76, 74, 1, 0, 0, 0, 76, 77, 1, 0, 0, 0, 77, 11, 1, 0, 0, 0, 78, 76, + 1, 0, 0, 0, 79, 80, 3, 14, 7, 0, 80, 81, 5, 11, 0, 0, 81, 82, 3, 18, 9, + 0, 82, 83, 5, 12, 0, 0, 83, 13, 1, 0, 0, 0, 84, 89, 5, 10, 0, 0, 85, 86, + 5, 21, 0, 0, 86, 87, 3, 16, 8, 0, 87, 88, 5, 22, 0, 0, 88, 90, 1, 0, 0, + 0, 89, 85, 1, 0, 0, 0, 89, 90, 1, 0, 0, 0, 90, 15, 1, 0, 0, 0, 91, 92, + 5, 4, 0, 0, 92, 17, 1, 0, 0, 0, 93, 98, 3, 20, 10, 0, 94, 95, 5, 15, 0, + 0, 95, 97, 3, 20, 10, 0, 96, 94, 1, 0, 0, 0, 97, 100, 1, 0, 0, 0, 98, 96, + 1, 0, 0, 0, 98, 99, 1, 0, 0, 0, 99, 102, 1, 0, 0, 0, 100, 98, 1, 0, 0, + 0, 101, 93, 1, 0, 0, 0, 101, 102, 1, 0, 0, 0, 102, 19, 1, 0, 0, 0, 103, + 108, 3, 22, 11, 0, 104, 105, 5, 10, 0, 0, 105, 106, 5, 1, 0, 0, 106, 108, + 3, 22, 11, 0, 107, 103, 1, 0, 0, 0, 107, 104, 1, 0, 0, 0, 108, 21, 1, 0, + 0, 0, 109, 117, 3, 26, 13, 0, 110, 117, 5, 7, 0, 0, 111, 117, 5, 8, 0, + 0, 112, 113, 5, 13, 0, 0, 113, 114, 3, 10, 5, 0, 114, 115, 5, 14, 0, 0, + 115, 117, 1, 0, 0, 0, 116, 109, 1, 0, 0, 0, 116, 110, 1, 0, 0, 0, 116, + 111, 1, 0, 0, 0, 116, 112, 1, 0, 0, 0, 117, 23, 1, 0, 0, 0, 118, 122, 5, + 9, 0, 0, 119, 122, 5, 8, 0, 0, 120, 122, 5, 5, 0, 0, 121, 118, 1, 0, 0, + 0, 121, 119, 1, 0, 0, 0, 121, 120, 1, 0, 0, 0, 122, 25, 1, 0, 0, 0, 123, + 124, 5, 6, 0, 0, 124, 27, 1, 0, 0, 0, 11, 31, 38, 61, 69, 76, 89, 98, 101, + 107, 116, 121, + } + deserializer := antlr.NewATNDeserializer(nil) + staticData.atn = deserializer.Deserialize(staticData.serializedATN) + atn := staticData.atn + staticData.decisionToDFA = make([]*antlr.DFA, len(atn.DecisionToState)) + decisionToDFA := staticData.decisionToDFA + for index, state := range atn.DecisionToState { + decisionToDFA[index] = antlr.NewDFA(state, index) + } +} + +// IRParserInit initializes any static state used to implement IRParser. By default the +// static state used to implement the parser is lazily initialized during the first call to +// NewIRParser(). You can call this function if you wish to initialize the static state ahead +// of time. +func IRParserInit() { + staticData := &IRParserStaticData + staticData.once.Do(irParserInit) +} + +// NewIRParser produces a new parser instance for the optional input antlr.TokenStream. +func NewIRParser(input antlr.TokenStream) *IRParser { + IRParserInit() + this := new(IRParser) + this.BaseParser = antlr.NewBaseParser(input) + staticData := &IRParserStaticData + this.Interpreter = antlr.NewParserATNSimulator(this, staticData.atn, staticData.decisionToDFA, staticData.PredictionContextCache) + this.RuleNames = staticData.RuleNames + this.LiteralNames = staticData.LiteralNames + this.SymbolicNames = staticData.SymbolicNames + this.GrammarFileName = "IR.g4" + + return this +} + +// IRParser tokens. +const ( + IRParserEOF = antlr.TokenEOF + IRParserT__0 = 1 + IRParserWS = 2 + IRParserNEWLINE = 3 + IRParserTYPE_KEYWORD = 4 + IRParserBOOL = 5 + IRParserREG = 6 + IRParserLABEL = 7 + IRParserINT = 8 + IRParserSTRING = 9 + IRParserIDENTIFIER = 10 + IRParserLPAREN = 11 + IRParserRPAREN = 12 + IRParserLBRACKET = 13 + IRParserRBRACKET = 14 + IRParserCOMMA = 15 + IRParserEQ = 16 + IRParserPLUS = 17 + IRParserMINUS = 18 + IRParserPLUS_EQ = 19 + IRParserMINUS_EQ = 20 + IRParserLT = 21 + IRParserGT = 22 + IRParserUNDERSCORE = 23 +) + +// IRParser rules. +const ( + IRParserRULE_program = 0 + IRParserRULE_line = 1 + IRParserRULE_labelMarker = 2 + IRParserRULE_instruction = 3 + IRParserRULE_dest = 4 + IRParserRULE_regList = 5 + IRParserRULE_instrCall = 6 + IRParserRULE_instrName = 7 + IRParserRULE_typeName = 8 + IRParserRULE_args = 9 + IRParserRULE_arg = 10 + IRParserRULE_value = 11 + IRParserRULE_const_ = 12 + IRParserRULE_reg = 13 +) + +// IProgramContext is an interface to support dynamic dispatch. +type IProgramContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + EOF() antlr.TerminalNode + AllLine() []ILineContext + Line(i int) ILineContext + + // IsProgramContext differentiates from other interfaces. + IsProgramContext() +} + +type ProgramContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyProgramContext() *ProgramContext { + var p = new(ProgramContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_program + return p +} + +func InitEmptyProgramContext(p *ProgramContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_program +} + +func (*ProgramContext) IsProgramContext() {} + +func NewProgramContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *ProgramContext { + var p = new(ProgramContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_program + + return p +} + +func (s *ProgramContext) GetParser() antlr.Parser { return s.parser } + +func (s *ProgramContext) EOF() antlr.TerminalNode { + return s.GetToken(IRParserEOF, 0) +} + +func (s *ProgramContext) AllLine() []ILineContext { + children := s.GetChildren() + len := 0 + for _, ctx := range children { + if _, ok := ctx.(ILineContext); ok { + len++ + } + } + + tst := make([]ILineContext, len) + i := 0 + for _, ctx := range children { + if t, ok := ctx.(ILineContext); ok { + tst[i] = t.(ILineContext) + i++ + } + } + + return tst +} + +func (s *ProgramContext) Line(i int) ILineContext { + var t antlr.RuleContext + j := 0 + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(ILineContext); ok { + if j == i { + t = ctx.(antlr.RuleContext) + break + } + j++ + } + } + + if t == nil { + return nil + } + + return t.(ILineContext) +} + +func (s *ProgramContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ProgramContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *ProgramContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterProgram(s) + } +} + +func (s *ProgramContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitProgram(s) + } +} + +func (p *IRParser) Program() (localctx IProgramContext) { + localctx = NewProgramContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 0, IRParserRULE_program) + var _la int + + p.EnterOuterAlt(localctx, 1) + p.SetState(31) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + + for (int64(_la) & ^0x3f) == 0 && ((int64(1)<<_la)&8398016) != 0 { + { + p.SetState(28) + p.Line() + } + + p.SetState(33) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + } + { + p.SetState(34) + p.Match(IRParserEOF) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// ILineContext is an interface to support dynamic dispatch. +type ILineContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + LabelMarker() ILabelMarkerContext + Instruction() IInstructionContext + + // IsLineContext differentiates from other interfaces. + IsLineContext() +} + +type LineContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyLineContext() *LineContext { + var p = new(LineContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_line + return p +} + +func InitEmptyLineContext(p *LineContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_line +} + +func (*LineContext) IsLineContext() {} + +func NewLineContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *LineContext { + var p = new(LineContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_line + + return p +} + +func (s *LineContext) GetParser() antlr.Parser { return s.parser } + +func (s *LineContext) LabelMarker() ILabelMarkerContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(ILabelMarkerContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(ILabelMarkerContext) +} + +func (s *LineContext) Instruction() IInstructionContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IInstructionContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IInstructionContext) +} + +func (s *LineContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *LineContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *LineContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterLine(s) + } +} + +func (s *LineContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitLine(s) + } +} + +func (p *IRParser) Line() (localctx ILineContext) { + localctx = NewLineContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 2, IRParserRULE_line) + p.SetState(38) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + + switch p.GetTokenStream().LA(1) { + case IRParserLABEL: + p.EnterOuterAlt(localctx, 1) + { + p.SetState(36) + p.LabelMarker() + } + + case IRParserREG, IRParserIDENTIFIER, IRParserLBRACKET, IRParserUNDERSCORE: + p.EnterOuterAlt(localctx, 2) + { + p.SetState(37) + p.Instruction() + } + + default: + p.SetError(antlr.NewNoViableAltException(p, nil, nil, nil, nil, nil)) + goto errorExit + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// ILabelMarkerContext is an interface to support dynamic dispatch. +type ILabelMarkerContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + LABEL() antlr.TerminalNode + + // IsLabelMarkerContext differentiates from other interfaces. + IsLabelMarkerContext() +} + +type LabelMarkerContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyLabelMarkerContext() *LabelMarkerContext { + var p = new(LabelMarkerContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_labelMarker + return p +} + +func InitEmptyLabelMarkerContext(p *LabelMarkerContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_labelMarker +} + +func (*LabelMarkerContext) IsLabelMarkerContext() {} + +func NewLabelMarkerContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *LabelMarkerContext { + var p = new(LabelMarkerContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_labelMarker + + return p +} + +func (s *LabelMarkerContext) GetParser() antlr.Parser { return s.parser } + +func (s *LabelMarkerContext) LABEL() antlr.TerminalNode { + return s.GetToken(IRParserLABEL, 0) +} + +func (s *LabelMarkerContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *LabelMarkerContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *LabelMarkerContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterLabelMarker(s) + } +} + +func (s *LabelMarkerContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitLabelMarker(s) + } +} + +func (p *IRParser) LabelMarker() (localctx ILabelMarkerContext) { + localctx = NewLabelMarkerContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 4, IRParserRULE_labelMarker) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(40) + p.Match(IRParserLABEL) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IInstructionContext is an interface to support dynamic dispatch. +type IInstructionContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + // IsInstructionContext differentiates from other interfaces. + IsInstructionContext() +} + +type InstructionContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyInstructionContext() *InstructionContext { + var p = new(InstructionContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_instruction + return p +} + +func InitEmptyInstructionContext(p *InstructionContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_instruction +} + +func (*InstructionContext) IsInstructionContext() {} + +func NewInstructionContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *InstructionContext { + var p = new(InstructionContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_instruction + + return p +} + +func (s *InstructionContext) GetParser() antlr.Parser { return s.parser } + +func (s *InstructionContext) CopyAll(ctx *InstructionContext) { + s.CopyFrom(&ctx.BaseParserRuleContext) +} + +func (s *InstructionContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *InstructionContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +type InstrNoDestContext struct { + InstructionContext +} + +func NewInstrNoDestContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *InstrNoDestContext { + var p = new(InstrNoDestContext) + + InitEmptyInstructionContext(&p.InstructionContext) + p.parser = parser + p.CopyAll(ctx.(*InstructionContext)) + + return p +} + +func (s *InstrNoDestContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *InstrNoDestContext) InstrCall() IInstrCallContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IInstrCallContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IInstrCallContext) +} + +func (s *InstrNoDestContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterInstrNoDest(s) + } +} + +func (s *InstrNoDestContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitInstrNoDest(s) + } +} + +type CompoundAssignInstrContext struct { + InstructionContext + left IRegContext + op antlr.Token + right IRegContext +} + +func NewCompoundAssignInstrContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *CompoundAssignInstrContext { + var p = new(CompoundAssignInstrContext) + + InitEmptyInstructionContext(&p.InstructionContext) + p.parser = parser + p.CopyAll(ctx.(*InstructionContext)) + + return p +} + +func (s *CompoundAssignInstrContext) GetOp() antlr.Token { return s.op } + +func (s *CompoundAssignInstrContext) SetOp(v antlr.Token) { s.op = v } + +func (s *CompoundAssignInstrContext) GetLeft() IRegContext { return s.left } + +func (s *CompoundAssignInstrContext) GetRight() IRegContext { return s.right } + +func (s *CompoundAssignInstrContext) SetLeft(v IRegContext) { s.left = v } + +func (s *CompoundAssignInstrContext) SetRight(v IRegContext) { s.right = v } + +func (s *CompoundAssignInstrContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *CompoundAssignInstrContext) AllReg() []IRegContext { + children := s.GetChildren() + len := 0 + for _, ctx := range children { + if _, ok := ctx.(IRegContext); ok { + len++ + } + } + + tst := make([]IRegContext, len) + i := 0 + for _, ctx := range children { + if t, ok := ctx.(IRegContext); ok { + tst[i] = t.(IRegContext) + i++ + } + } + + return tst +} + +func (s *CompoundAssignInstrContext) Reg(i int) IRegContext { + var t antlr.RuleContext + j := 0 + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegContext); ok { + if j == i { + t = ctx.(antlr.RuleContext) + break + } + j++ + } + } + + if t == nil { + return nil + } + + return t.(IRegContext) +} + +func (s *CompoundAssignInstrContext) PLUS_EQ() antlr.TerminalNode { + return s.GetToken(IRParserPLUS_EQ, 0) +} + +func (s *CompoundAssignInstrContext) MINUS_EQ() antlr.TerminalNode { + return s.GetToken(IRParserMINUS_EQ, 0) +} + +func (s *CompoundAssignInstrContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterCompoundAssignInstr(s) + } +} + +func (s *CompoundAssignInstrContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitCompoundAssignInstr(s) + } +} + +type ConstAssignContext struct { + InstructionContext +} + +func NewConstAssignContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ConstAssignContext { + var p = new(ConstAssignContext) + + InitEmptyInstructionContext(&p.InstructionContext) + p.parser = parser + p.CopyAll(ctx.(*InstructionContext)) + + return p +} + +func (s *ConstAssignContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ConstAssignContext) Dest() IDestContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IDestContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IDestContext) +} + +func (s *ConstAssignContext) EQ() antlr.TerminalNode { + return s.GetToken(IRParserEQ, 0) +} + +func (s *ConstAssignContext) Const_() IConst_Context { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IConst_Context); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IConst_Context) +} + +func (s *ConstAssignContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterConstAssign(s) + } +} + +func (s *ConstAssignContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitConstAssign(s) + } +} + +type InstrWithDestContext struct { + InstructionContext +} + +func NewInstrWithDestContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *InstrWithDestContext { + var p = new(InstrWithDestContext) + + InitEmptyInstructionContext(&p.InstructionContext) + p.parser = parser + p.CopyAll(ctx.(*InstructionContext)) + + return p +} + +func (s *InstrWithDestContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *InstrWithDestContext) Dest() IDestContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IDestContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IDestContext) +} + +func (s *InstrWithDestContext) EQ() antlr.TerminalNode { + return s.GetToken(IRParserEQ, 0) +} + +func (s *InstrWithDestContext) InstrCall() IInstrCallContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IInstrCallContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IInstrCallContext) +} + +func (s *InstrWithDestContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterInstrWithDest(s) + } +} + +func (s *InstrWithDestContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitInstrWithDest(s) + } +} + +type InfixInstrContext struct { + InstructionContext + left IRegContext + op antlr.Token + right IRegContext +} + +func NewInfixInstrContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *InfixInstrContext { + var p = new(InfixInstrContext) + + InitEmptyInstructionContext(&p.InstructionContext) + p.parser = parser + p.CopyAll(ctx.(*InstructionContext)) + + return p +} + +func (s *InfixInstrContext) GetOp() antlr.Token { return s.op } + +func (s *InfixInstrContext) SetOp(v antlr.Token) { s.op = v } + +func (s *InfixInstrContext) GetLeft() IRegContext { return s.left } + +func (s *InfixInstrContext) GetRight() IRegContext { return s.right } + +func (s *InfixInstrContext) SetLeft(v IRegContext) { s.left = v } + +func (s *InfixInstrContext) SetRight(v IRegContext) { s.right = v } + +func (s *InfixInstrContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *InfixInstrContext) Dest() IDestContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IDestContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IDestContext) +} + +func (s *InfixInstrContext) EQ() antlr.TerminalNode { + return s.GetToken(IRParserEQ, 0) +} + +func (s *InfixInstrContext) AllReg() []IRegContext { + children := s.GetChildren() + len := 0 + for _, ctx := range children { + if _, ok := ctx.(IRegContext); ok { + len++ + } + } + + tst := make([]IRegContext, len) + i := 0 + for _, ctx := range children { + if t, ok := ctx.(IRegContext); ok { + tst[i] = t.(IRegContext) + i++ + } + } + + return tst +} + +func (s *InfixInstrContext) Reg(i int) IRegContext { + var t antlr.RuleContext + j := 0 + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegContext); ok { + if j == i { + t = ctx.(antlr.RuleContext) + break + } + j++ + } + } + + if t == nil { + return nil + } + + return t.(IRegContext) +} + +func (s *InfixInstrContext) PLUS() antlr.TerminalNode { + return s.GetToken(IRParserPLUS, 0) +} + +func (s *InfixInstrContext) MINUS() antlr.TerminalNode { + return s.GetToken(IRParserMINUS, 0) +} + +func (s *InfixInstrContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterInfixInstr(s) + } +} + +func (s *InfixInstrContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitInfixInstr(s) + } +} + +func (p *IRParser) Instruction() (localctx IInstructionContext) { + localctx = NewInstructionContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 6, IRParserRULE_instruction) + var _la int + + p.SetState(61) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + + switch p.GetInterpreter().AdaptivePredict(p.BaseParser, p.GetTokenStream(), 2, p.GetParserRuleContext()) { + case 1: + localctx = NewInstrWithDestContext(p, localctx) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(42) + p.Dest() + } + { + p.SetState(43) + p.Match(IRParserEQ) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(44) + p.InstrCall() + } + + case 2: + localctx = NewInstrNoDestContext(p, localctx) + p.EnterOuterAlt(localctx, 2) + { + p.SetState(46) + p.InstrCall() + } + + case 3: + localctx = NewConstAssignContext(p, localctx) + p.EnterOuterAlt(localctx, 3) + { + p.SetState(47) + p.Dest() + } + { + p.SetState(48) + p.Match(IRParserEQ) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(49) + p.Const_() + } + + case 4: + localctx = NewInfixInstrContext(p, localctx) + p.EnterOuterAlt(localctx, 4) + { + p.SetState(51) + p.Dest() + } + { + p.SetState(52) + p.Match(IRParserEQ) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(53) + + var _x = p.Reg() + + localctx.(*InfixInstrContext).left = _x + } + { + p.SetState(54) + + var _lt = p.GetTokenStream().LT(1) + + localctx.(*InfixInstrContext).op = _lt + + _la = p.GetTokenStream().LA(1) + + if !(_la == IRParserPLUS || _la == IRParserMINUS) { + var _ri = p.GetErrorHandler().RecoverInline(p) + + localctx.(*InfixInstrContext).op = _ri + } else { + p.GetErrorHandler().ReportMatch(p) + p.Consume() + } + } + { + p.SetState(55) + + var _x = p.Reg() + + localctx.(*InfixInstrContext).right = _x + } + + case 5: + localctx = NewCompoundAssignInstrContext(p, localctx) + p.EnterOuterAlt(localctx, 5) + { + p.SetState(57) + + var _x = p.Reg() + + localctx.(*CompoundAssignInstrContext).left = _x + } + { + p.SetState(58) + + var _lt = p.GetTokenStream().LT(1) + + localctx.(*CompoundAssignInstrContext).op = _lt + + _la = p.GetTokenStream().LA(1) + + if !(_la == IRParserPLUS_EQ || _la == IRParserMINUS_EQ) { + var _ri = p.GetErrorHandler().RecoverInline(p) + + localctx.(*CompoundAssignInstrContext).op = _ri + } else { + p.GetErrorHandler().ReportMatch(p) + p.Consume() + } + } + { + p.SetState(59) + + var _x = p.Reg() + + localctx.(*CompoundAssignInstrContext).right = _x + } + + case antlr.ATNInvalidAltNumber: + goto errorExit + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IDestContext is an interface to support dynamic dispatch. +type IDestContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + // IsDestContext differentiates from other interfaces. + IsDestContext() +} + +type DestContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyDestContext() *DestContext { + var p = new(DestContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_dest + return p +} + +func InitEmptyDestContext(p *DestContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_dest +} + +func (*DestContext) IsDestContext() {} + +func NewDestContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *DestContext { + var p = new(DestContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_dest + + return p +} + +func (s *DestContext) GetParser() antlr.Parser { return s.parser } + +func (s *DestContext) CopyAll(ctx *DestContext) { + s.CopyFrom(&ctx.BaseParserRuleContext) +} + +func (s *DestContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *DestContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +type DestRegContext struct { + DestContext +} + +func NewDestRegContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *DestRegContext { + var p = new(DestRegContext) + + InitEmptyDestContext(&p.DestContext) + p.parser = parser + p.CopyAll(ctx.(*DestContext)) + + return p +} + +func (s *DestRegContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *DestRegContext) Reg() IRegContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IRegContext) +} + +func (s *DestRegContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterDestReg(s) + } +} + +func (s *DestRegContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitDestReg(s) + } +} + +type DestDiscardContext struct { + DestContext +} + +func NewDestDiscardContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *DestDiscardContext { + var p = new(DestDiscardContext) + + InitEmptyDestContext(&p.DestContext) + p.parser = parser + p.CopyAll(ctx.(*DestContext)) + + return p +} + +func (s *DestDiscardContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *DestDiscardContext) UNDERSCORE() antlr.TerminalNode { + return s.GetToken(IRParserUNDERSCORE, 0) +} + +func (s *DestDiscardContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterDestDiscard(s) + } +} + +func (s *DestDiscardContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitDestDiscard(s) + } +} + +type DestListContext struct { + DestContext +} + +func NewDestListContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *DestListContext { + var p = new(DestListContext) + + InitEmptyDestContext(&p.DestContext) + p.parser = parser + p.CopyAll(ctx.(*DestContext)) + + return p +} + +func (s *DestListContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *DestListContext) LBRACKET() antlr.TerminalNode { + return s.GetToken(IRParserLBRACKET, 0) +} + +func (s *DestListContext) RegList() IRegListContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegListContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IRegListContext) +} + +func (s *DestListContext) RBRACKET() antlr.TerminalNode { + return s.GetToken(IRParserRBRACKET, 0) +} + +func (s *DestListContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterDestList(s) + } +} + +func (s *DestListContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitDestList(s) + } +} + +func (p *IRParser) Dest() (localctx IDestContext) { + localctx = NewDestContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 8, IRParserRULE_dest) + p.SetState(69) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + + switch p.GetTokenStream().LA(1) { + case IRParserREG: + localctx = NewDestRegContext(p, localctx) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(63) + p.Reg() + } + + case IRParserUNDERSCORE: + localctx = NewDestDiscardContext(p, localctx) + p.EnterOuterAlt(localctx, 2) + { + p.SetState(64) + p.Match(IRParserUNDERSCORE) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + case IRParserLBRACKET: + localctx = NewDestListContext(p, localctx) + p.EnterOuterAlt(localctx, 3) + { + p.SetState(65) + p.Match(IRParserLBRACKET) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(66) + p.RegList() + } + { + p.SetState(67) + p.Match(IRParserRBRACKET) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + default: + p.SetError(antlr.NewNoViableAltException(p, nil, nil, nil, nil, nil)) + goto errorExit + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IRegListContext is an interface to support dynamic dispatch. +type IRegListContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + AllReg() []IRegContext + Reg(i int) IRegContext + AllCOMMA() []antlr.TerminalNode + COMMA(i int) antlr.TerminalNode + + // IsRegListContext differentiates from other interfaces. + IsRegListContext() +} + +type RegListContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyRegListContext() *RegListContext { + var p = new(RegListContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_regList + return p +} + +func InitEmptyRegListContext(p *RegListContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_regList +} + +func (*RegListContext) IsRegListContext() {} + +func NewRegListContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *RegListContext { + var p = new(RegListContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_regList + + return p +} + +func (s *RegListContext) GetParser() antlr.Parser { return s.parser } + +func (s *RegListContext) AllReg() []IRegContext { + children := s.GetChildren() + len := 0 + for _, ctx := range children { + if _, ok := ctx.(IRegContext); ok { + len++ + } + } + + tst := make([]IRegContext, len) + i := 0 + for _, ctx := range children { + if t, ok := ctx.(IRegContext); ok { + tst[i] = t.(IRegContext) + i++ + } + } + + return tst +} + +func (s *RegListContext) Reg(i int) IRegContext { + var t antlr.RuleContext + j := 0 + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegContext); ok { + if j == i { + t = ctx.(antlr.RuleContext) + break + } + j++ + } + } + + if t == nil { + return nil + } + + return t.(IRegContext) +} + +func (s *RegListContext) AllCOMMA() []antlr.TerminalNode { + return s.GetTokens(IRParserCOMMA) +} + +func (s *RegListContext) COMMA(i int) antlr.TerminalNode { + return s.GetToken(IRParserCOMMA, i) +} + +func (s *RegListContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *RegListContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *RegListContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterRegList(s) + } +} + +func (s *RegListContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitRegList(s) + } +} + +func (p *IRParser) RegList() (localctx IRegListContext) { + localctx = NewRegListContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 10, IRParserRULE_regList) + var _la int + + p.EnterOuterAlt(localctx, 1) + { + p.SetState(71) + p.Reg() + } + p.SetState(76) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + + for _la == IRParserCOMMA { + { + p.SetState(72) + p.Match(IRParserCOMMA) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(73) + p.Reg() + } + + p.SetState(78) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IInstrCallContext is an interface to support dynamic dispatch. +type IInstrCallContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + InstrName() IInstrNameContext + LPAREN() antlr.TerminalNode + Args() IArgsContext + RPAREN() antlr.TerminalNode + + // IsInstrCallContext differentiates from other interfaces. + IsInstrCallContext() +} + +type InstrCallContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyInstrCallContext() *InstrCallContext { + var p = new(InstrCallContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_instrCall + return p +} + +func InitEmptyInstrCallContext(p *InstrCallContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_instrCall +} + +func (*InstrCallContext) IsInstrCallContext() {} + +func NewInstrCallContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *InstrCallContext { + var p = new(InstrCallContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_instrCall + + return p +} + +func (s *InstrCallContext) GetParser() antlr.Parser { return s.parser } + +func (s *InstrCallContext) InstrName() IInstrNameContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IInstrNameContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IInstrNameContext) +} + +func (s *InstrCallContext) LPAREN() antlr.TerminalNode { + return s.GetToken(IRParserLPAREN, 0) +} + +func (s *InstrCallContext) Args() IArgsContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IArgsContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IArgsContext) +} + +func (s *InstrCallContext) RPAREN() antlr.TerminalNode { + return s.GetToken(IRParserRPAREN, 0) +} + +func (s *InstrCallContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *InstrCallContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *InstrCallContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterInstrCall(s) + } +} + +func (s *InstrCallContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitInstrCall(s) + } +} + +func (p *IRParser) InstrCall() (localctx IInstrCallContext) { + localctx = NewInstrCallContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 12, IRParserRULE_instrCall) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(79) + p.InstrName() + } + { + p.SetState(80) + p.Match(IRParserLPAREN) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(81) + p.Args() + } + { + p.SetState(82) + p.Match(IRParserRPAREN) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IInstrNameContext is an interface to support dynamic dispatch. +type IInstrNameContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + IDENTIFIER() antlr.TerminalNode + LT() antlr.TerminalNode + TypeName() ITypeNameContext + GT() antlr.TerminalNode + + // IsInstrNameContext differentiates from other interfaces. + IsInstrNameContext() +} + +type InstrNameContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyInstrNameContext() *InstrNameContext { + var p = new(InstrNameContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_instrName + return p +} + +func InitEmptyInstrNameContext(p *InstrNameContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_instrName +} + +func (*InstrNameContext) IsInstrNameContext() {} + +func NewInstrNameContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *InstrNameContext { + var p = new(InstrNameContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_instrName + + return p +} + +func (s *InstrNameContext) GetParser() antlr.Parser { return s.parser } + +func (s *InstrNameContext) IDENTIFIER() antlr.TerminalNode { + return s.GetToken(IRParserIDENTIFIER, 0) +} + +func (s *InstrNameContext) LT() antlr.TerminalNode { + return s.GetToken(IRParserLT, 0) +} + +func (s *InstrNameContext) TypeName() ITypeNameContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(ITypeNameContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(ITypeNameContext) +} + +func (s *InstrNameContext) GT() antlr.TerminalNode { + return s.GetToken(IRParserGT, 0) +} + +func (s *InstrNameContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *InstrNameContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *InstrNameContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterInstrName(s) + } +} + +func (s *InstrNameContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitInstrName(s) + } +} + +func (p *IRParser) InstrName() (localctx IInstrNameContext) { + localctx = NewInstrNameContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 14, IRParserRULE_instrName) + var _la int + + p.EnterOuterAlt(localctx, 1) + { + p.SetState(84) + p.Match(IRParserIDENTIFIER) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + p.SetState(89) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + + if _la == IRParserLT { + { + p.SetState(85) + p.Match(IRParserLT) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(86) + p.TypeName() + } + { + p.SetState(87) + p.Match(IRParserGT) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// ITypeNameContext is an interface to support dynamic dispatch. +type ITypeNameContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + TYPE_KEYWORD() antlr.TerminalNode + + // IsTypeNameContext differentiates from other interfaces. + IsTypeNameContext() +} + +type TypeNameContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyTypeNameContext() *TypeNameContext { + var p = new(TypeNameContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_typeName + return p +} + +func InitEmptyTypeNameContext(p *TypeNameContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_typeName +} + +func (*TypeNameContext) IsTypeNameContext() {} + +func NewTypeNameContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *TypeNameContext { + var p = new(TypeNameContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_typeName + + return p +} + +func (s *TypeNameContext) GetParser() antlr.Parser { return s.parser } + +func (s *TypeNameContext) TYPE_KEYWORD() antlr.TerminalNode { + return s.GetToken(IRParserTYPE_KEYWORD, 0) +} + +func (s *TypeNameContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *TypeNameContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *TypeNameContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterTypeName(s) + } +} + +func (s *TypeNameContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitTypeName(s) + } +} + +func (p *IRParser) TypeName() (localctx ITypeNameContext) { + localctx = NewTypeNameContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 16, IRParserRULE_typeName) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(91) + p.Match(IRParserTYPE_KEYWORD) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IArgsContext is an interface to support dynamic dispatch. +type IArgsContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + AllArg() []IArgContext + Arg(i int) IArgContext + AllCOMMA() []antlr.TerminalNode + COMMA(i int) antlr.TerminalNode + + // IsArgsContext differentiates from other interfaces. + IsArgsContext() +} + +type ArgsContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyArgsContext() *ArgsContext { + var p = new(ArgsContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_args + return p +} + +func InitEmptyArgsContext(p *ArgsContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_args +} + +func (*ArgsContext) IsArgsContext() {} + +func NewArgsContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *ArgsContext { + var p = new(ArgsContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_args + + return p +} + +func (s *ArgsContext) GetParser() antlr.Parser { return s.parser } + +func (s *ArgsContext) AllArg() []IArgContext { + children := s.GetChildren() + len := 0 + for _, ctx := range children { + if _, ok := ctx.(IArgContext); ok { + len++ + } + } + + tst := make([]IArgContext, len) + i := 0 + for _, ctx := range children { + if t, ok := ctx.(IArgContext); ok { + tst[i] = t.(IArgContext) + i++ + } + } + + return tst +} + +func (s *ArgsContext) Arg(i int) IArgContext { + var t antlr.RuleContext + j := 0 + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IArgContext); ok { + if j == i { + t = ctx.(antlr.RuleContext) + break + } + j++ + } + } + + if t == nil { + return nil + } + + return t.(IArgContext) +} + +func (s *ArgsContext) AllCOMMA() []antlr.TerminalNode { + return s.GetTokens(IRParserCOMMA) +} + +func (s *ArgsContext) COMMA(i int) antlr.TerminalNode { + return s.GetToken(IRParserCOMMA, i) +} + +func (s *ArgsContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ArgsContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *ArgsContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterArgs(s) + } +} + +func (s *ArgsContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitArgs(s) + } +} + +func (p *IRParser) Args() (localctx IArgsContext) { + localctx = NewArgsContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 18, IRParserRULE_args) + var _la int + + p.EnterOuterAlt(localctx, 1) + p.SetState(101) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + + if (int64(_la) & ^0x3f) == 0 && ((int64(1)<<_la)&9664) != 0 { + { + p.SetState(93) + p.Arg() + } + p.SetState(98) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + + for _la == IRParserCOMMA { + { + p.SetState(94) + p.Match(IRParserCOMMA) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(95) + p.Arg() + } + + p.SetState(100) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + _la = p.GetTokenStream().LA(1) + } + + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IArgContext is an interface to support dynamic dispatch. +type IArgContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + // IsArgContext differentiates from other interfaces. + IsArgContext() +} + +type ArgContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyArgContext() *ArgContext { + var p = new(ArgContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_arg + return p +} + +func InitEmptyArgContext(p *ArgContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_arg +} + +func (*ArgContext) IsArgContext() {} + +func NewArgContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *ArgContext { + var p = new(ArgContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_arg + + return p +} + +func (s *ArgContext) GetParser() antlr.Parser { return s.parser } + +func (s *ArgContext) CopyAll(ctx *ArgContext) { + s.CopyFrom(&ctx.BaseParserRuleContext) +} + +func (s *ArgContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ArgContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +type PositionalArgContext struct { + ArgContext +} + +func NewPositionalArgContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *PositionalArgContext { + var p = new(PositionalArgContext) + + InitEmptyArgContext(&p.ArgContext) + p.parser = parser + p.CopyAll(ctx.(*ArgContext)) + + return p +} + +func (s *PositionalArgContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *PositionalArgContext) Value() IValueContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IValueContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IValueContext) +} + +func (s *PositionalArgContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterPositionalArg(s) + } +} + +func (s *PositionalArgContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitPositionalArg(s) + } +} + +type LabeledArgContext struct { + ArgContext +} + +func NewLabeledArgContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *LabeledArgContext { + var p = new(LabeledArgContext) + + InitEmptyArgContext(&p.ArgContext) + p.parser = parser + p.CopyAll(ctx.(*ArgContext)) + + return p +} + +func (s *LabeledArgContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *LabeledArgContext) IDENTIFIER() antlr.TerminalNode { + return s.GetToken(IRParserIDENTIFIER, 0) +} + +func (s *LabeledArgContext) Value() IValueContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IValueContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IValueContext) +} + +func (s *LabeledArgContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterLabeledArg(s) + } +} + +func (s *LabeledArgContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitLabeledArg(s) + } +} + +func (p *IRParser) Arg() (localctx IArgContext) { + localctx = NewArgContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 20, IRParserRULE_arg) + p.SetState(107) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + + switch p.GetTokenStream().LA(1) { + case IRParserREG, IRParserLABEL, IRParserINT, IRParserLBRACKET: + localctx = NewPositionalArgContext(p, localctx) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(103) + p.Value() + } + + case IRParserIDENTIFIER: + localctx = NewLabeledArgContext(p, localctx) + p.EnterOuterAlt(localctx, 2) + { + p.SetState(104) + p.Match(IRParserIDENTIFIER) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(105) + p.Match(IRParserT__0) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(106) + p.Value() + } + + default: + p.SetError(antlr.NewNoViableAltException(p, nil, nil, nil, nil, nil)) + goto errorExit + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IValueContext is an interface to support dynamic dispatch. +type IValueContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + // IsValueContext differentiates from other interfaces. + IsValueContext() +} + +type ValueContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyValueContext() *ValueContext { + var p = new(ValueContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_value + return p +} + +func InitEmptyValueContext(p *ValueContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_value +} + +func (*ValueContext) IsValueContext() {} + +func NewValueContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *ValueContext { + var p = new(ValueContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_value + + return p +} + +func (s *ValueContext) GetParser() antlr.Parser { return s.parser } + +func (s *ValueContext) CopyAll(ctx *ValueContext) { + s.CopyFrom(&ctx.BaseParserRuleContext) +} + +func (s *ValueContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ValueContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +type ValRegContext struct { + ValueContext +} + +func NewValRegContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ValRegContext { + var p = new(ValRegContext) + + InitEmptyValueContext(&p.ValueContext) + p.parser = parser + p.CopyAll(ctx.(*ValueContext)) + + return p +} + +func (s *ValRegContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ValRegContext) Reg() IRegContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IRegContext) +} + +func (s *ValRegContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterValReg(s) + } +} + +func (s *ValRegContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitValReg(s) + } +} + +type ValIntContext struct { + ValueContext +} + +func NewValIntContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ValIntContext { + var p = new(ValIntContext) + + InitEmptyValueContext(&p.ValueContext) + p.parser = parser + p.CopyAll(ctx.(*ValueContext)) + + return p +} + +func (s *ValIntContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ValIntContext) INT() antlr.TerminalNode { + return s.GetToken(IRParserINT, 0) +} + +func (s *ValIntContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterValInt(s) + } +} + +func (s *ValIntContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitValInt(s) + } +} + +type ValLabelContext struct { + ValueContext +} + +func NewValLabelContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ValLabelContext { + var p = new(ValLabelContext) + + InitEmptyValueContext(&p.ValueContext) + p.parser = parser + p.CopyAll(ctx.(*ValueContext)) + + return p +} + +func (s *ValLabelContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ValLabelContext) LABEL() antlr.TerminalNode { + return s.GetToken(IRParserLABEL, 0) +} + +func (s *ValLabelContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterValLabel(s) + } +} + +func (s *ValLabelContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitValLabel(s) + } +} + +type ValRegListContext struct { + ValueContext +} + +func NewValRegListContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ValRegListContext { + var p = new(ValRegListContext) + + InitEmptyValueContext(&p.ValueContext) + p.parser = parser + p.CopyAll(ctx.(*ValueContext)) + + return p +} + +func (s *ValRegListContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ValRegListContext) LBRACKET() antlr.TerminalNode { + return s.GetToken(IRParserLBRACKET, 0) +} + +func (s *ValRegListContext) RegList() IRegListContext { + var t antlr.RuleContext + for _, ctx := range s.GetChildren() { + if _, ok := ctx.(IRegListContext); ok { + t = ctx.(antlr.RuleContext) + break + } + } + + if t == nil { + return nil + } + + return t.(IRegListContext) +} + +func (s *ValRegListContext) RBRACKET() antlr.TerminalNode { + return s.GetToken(IRParserRBRACKET, 0) +} + +func (s *ValRegListContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterValRegList(s) + } +} + +func (s *ValRegListContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitValRegList(s) + } +} + +func (p *IRParser) Value() (localctx IValueContext) { + localctx = NewValueContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 22, IRParserRULE_value) + p.SetState(116) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + + switch p.GetTokenStream().LA(1) { + case IRParserREG: + localctx = NewValRegContext(p, localctx) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(109) + p.Reg() + } + + case IRParserLABEL: + localctx = NewValLabelContext(p, localctx) + p.EnterOuterAlt(localctx, 2) + { + p.SetState(110) + p.Match(IRParserLABEL) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + case IRParserINT: + localctx = NewValIntContext(p, localctx) + p.EnterOuterAlt(localctx, 3) + { + p.SetState(111) + p.Match(IRParserINT) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + case IRParserLBRACKET: + localctx = NewValRegListContext(p, localctx) + p.EnterOuterAlt(localctx, 4) + { + p.SetState(112) + p.Match(IRParserLBRACKET) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + { + p.SetState(113) + p.RegList() + } + { + p.SetState(114) + p.Match(IRParserRBRACKET) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + default: + p.SetError(antlr.NewNoViableAltException(p, nil, nil, nil, nil, nil)) + goto errorExit + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IConst_Context is an interface to support dynamic dispatch. +type IConst_Context interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + // IsConst_Context differentiates from other interfaces. + IsConst_Context() +} + +type Const_Context struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyConst_Context() *Const_Context { + var p = new(Const_Context) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_const_ + return p +} + +func InitEmptyConst_Context(p *Const_Context) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_const_ +} + +func (*Const_Context) IsConst_Context() {} + +func NewConst_Context(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *Const_Context { + var p = new(Const_Context) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_const_ + + return p +} + +func (s *Const_Context) GetParser() antlr.Parser { return s.parser } + +func (s *Const_Context) CopyAll(ctx *Const_Context) { + s.CopyFrom(&ctx.BaseParserRuleContext) +} + +func (s *Const_Context) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *Const_Context) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +type ConstStringContext struct { + Const_Context +} + +func NewConstStringContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ConstStringContext { + var p = new(ConstStringContext) + + InitEmptyConst_Context(&p.Const_Context) + p.parser = parser + p.CopyAll(ctx.(*Const_Context)) + + return p +} + +func (s *ConstStringContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ConstStringContext) STRING() antlr.TerminalNode { + return s.GetToken(IRParserSTRING, 0) +} + +func (s *ConstStringContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterConstString(s) + } +} + +func (s *ConstStringContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitConstString(s) + } +} + +type ConstIntContext struct { + Const_Context +} + +func NewConstIntContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ConstIntContext { + var p = new(ConstIntContext) + + InitEmptyConst_Context(&p.Const_Context) + p.parser = parser + p.CopyAll(ctx.(*Const_Context)) + + return p +} + +func (s *ConstIntContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ConstIntContext) INT() antlr.TerminalNode { + return s.GetToken(IRParserINT, 0) +} + +func (s *ConstIntContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterConstInt(s) + } +} + +func (s *ConstIntContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitConstInt(s) + } +} + +type ConstBoolContext struct { + Const_Context +} + +func NewConstBoolContext(parser antlr.Parser, ctx antlr.ParserRuleContext) *ConstBoolContext { + var p = new(ConstBoolContext) + + InitEmptyConst_Context(&p.Const_Context) + p.parser = parser + p.CopyAll(ctx.(*Const_Context)) + + return p +} + +func (s *ConstBoolContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *ConstBoolContext) BOOL() antlr.TerminalNode { + return s.GetToken(IRParserBOOL, 0) +} + +func (s *ConstBoolContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterConstBool(s) + } +} + +func (s *ConstBoolContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitConstBool(s) + } +} + +func (p *IRParser) Const_() (localctx IConst_Context) { + localctx = NewConst_Context(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 24, IRParserRULE_const_) + p.SetState(121) + p.GetErrorHandler().Sync(p) + if p.HasError() { + goto errorExit + } + + switch p.GetTokenStream().LA(1) { + case IRParserSTRING: + localctx = NewConstStringContext(p, localctx) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(118) + p.Match(IRParserSTRING) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + case IRParserINT: + localctx = NewConstIntContext(p, localctx) + p.EnterOuterAlt(localctx, 2) + { + p.SetState(119) + p.Match(IRParserINT) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + case IRParserBOOL: + localctx = NewConstBoolContext(p, localctx) + p.EnterOuterAlt(localctx, 3) + { + p.SetState(120) + p.Match(IRParserBOOL) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + + default: + p.SetError(antlr.NewNoViableAltException(p, nil, nil, nil, nil, nil)) + goto errorExit + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} + +// IRegContext is an interface to support dynamic dispatch. +type IRegContext interface { + antlr.ParserRuleContext + + // GetParser returns the parser. + GetParser() antlr.Parser + + // Getter signatures + REG() antlr.TerminalNode + + // IsRegContext differentiates from other interfaces. + IsRegContext() +} + +type RegContext struct { + antlr.BaseParserRuleContext + parser antlr.Parser +} + +func NewEmptyRegContext() *RegContext { + var p = new(RegContext) + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_reg + return p +} + +func InitEmptyRegContext(p *RegContext) { + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, nil, -1) + p.RuleIndex = IRParserRULE_reg +} + +func (*RegContext) IsRegContext() {} + +func NewRegContext(parser antlr.Parser, parent antlr.ParserRuleContext, invokingState int) *RegContext { + var p = new(RegContext) + + antlr.InitBaseParserRuleContext(&p.BaseParserRuleContext, parent, invokingState) + + p.parser = parser + p.RuleIndex = IRParserRULE_reg + + return p +} + +func (s *RegContext) GetParser() antlr.Parser { return s.parser } + +func (s *RegContext) REG() antlr.TerminalNode { + return s.GetToken(IRParserREG, 0) +} + +func (s *RegContext) GetRuleContext() antlr.RuleContext { + return s +} + +func (s *RegContext) ToStringTree(ruleNames []string, recog antlr.Recognizer) string { + return antlr.TreesStringTree(s, ruleNames, recog) +} + +func (s *RegContext) EnterRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.EnterReg(s) + } +} + +func (s *RegContext) ExitRule(listener antlr.ParseTreeListener) { + if listenerT, ok := listener.(IRListener); ok { + listenerT.ExitReg(s) + } +} + +func (p *IRParser) Reg() (localctx IRegContext) { + localctx = NewRegContext(p, p.GetParserRuleContext(), p.GetState()) + p.EnterRule(localctx, 26, IRParserRULE_reg) + p.EnterOuterAlt(localctx, 1) + { + p.SetState(123) + p.Match(IRParserREG) + if p.HasError() { + // Recognition error - abort rule + goto errorExit + } + } + +errorExit: + if p.HasError() { + v := p.GetError() + localctx.SetException(v) + p.GetErrorHandler().ReportError(p, v) + p.GetErrorHandler().Recover(p, v) + p.SetError(nil) + } + p.ExitRule() + return localctx + goto errorExit // Trick to prevent compiler error if the label is not used +} diff --git a/internal/ir/internal/syntax/ast.go b/internal/ir/internal/syntax/ast.go new file mode 100644 index 00000000..f3e4edea --- /dev/null +++ b/internal/ir/internal/syntax/ast.go @@ -0,0 +1,125 @@ +package syntax + +import ( + "github.com/formancehq/numscript/internal/parser" +) + +// ---- AST types for the IR textual format ---- + +// Program is the root of the parsed IR text. +type Program struct { + Stmts []Stmt +} + +// Stmt is either a LabelStmt or an InstrStmt. +type Stmt interface { + stmt() +} + +// LabelStmt represents a label marker line, e.g. "#inorder_end_0". +type LabelStmt struct { + Range parser.Range + Name string +} + +func (*LabelStmt) stmt() {} + +// InstrStmt represents one instruction line. +type InstrStmt struct { + Range parser.Range + + // Dest is the destination; nil when the instruction has no dest (e.g. "set_current_asset($r3)"). + Dest *Dest + + // One of these is set: + Call *InstrCall // e.g. "mk_monetary($r0, $r1)" + Const *Const // e.g. "$r0 = \"USD/2\"" or "$r0 = 42" + Infix *Infix // e.g. "$r3 = $r1 + $r2" + CompoundAssign *Infix // e.g. "$r5 += $r9" (Left is implicit from Dest) +} + +func (*InstrStmt) stmt() {} + +// Dest is the LHS of an assignment. +type Dest struct { + Range parser.Range + Kind DestKind + Regs []RegRef // non-empty for DestReg and DestList +} + +type DestKind int + +const ( + DestReg DestKind = iota // single register + DestDiscard // _ + DestList // [$r0, $r1] +) + +// RegRef is a parsed register reference, e.g. "$r0". +type RegRef struct { + Range parser.Range + Name string +} + +// InstrCall is a function-call instruction: name(args). +type InstrCall struct { + Range parser.Range + Name string // e.g. "mk_monetary", "pull_account" + TypeParam string // "" if none, else "int", "str", etc. + Args []Arg // may be empty +} + +// Arg is a single argument to an instruction. +type Arg struct { + Range parser.Range + Label string // empty for positional args; "account", "cap", etc. for labeled + Value Value +} + +// Value is a value that can appear as an argument. +type Value struct { + Range parser.Range + Kind ValueKind + + // Exactly one of these is set, depending on Kind: + Reg *RegRef // ValReg + Label *string // ValLabel: the label name without '#' + Int *string // ValInt: raw numeric string + Regs *[]RegRef // ValRegList +} + +type ValueKind int + +const ( + ValReg ValueKind = iota // $r0 + ValLabel // #my_label + ValInt // 42 + ValRegList // [$r0, $r1] +) + +// Const is a constant literal: a string, an integer or a bool. +type Const struct { + Range parser.Range + Kind ConstKind + + // Exactly one is set: + StrVal *string + IntVal *string // raw numeric string + BoolVal *bool +} + +type ConstKind int + +const ( + ConstString ConstKind = iota + ConstInt + ConstBool +) + +// Infix is a binary operation with infix syntax. +type Infix struct { + Range parser.Range + Op string // "+" or "-" + Left RegRef + Right RegRef +} diff --git a/internal/ir/internal/syntax/parser.go b/internal/ir/internal/syntax/parser.go new file mode 100644 index 00000000..a0858f93 --- /dev/null +++ b/internal/ir/internal/syntax/parser.go @@ -0,0 +1,354 @@ +package syntax + +import ( + "strconv" + + "github.com/formancehq/numscript/internal/parser" + + "github.com/antlr4-go/antlr/v4" + antlrParser "github.com/formancehq/numscript/internal/ir/internal/syntax/antlrParser" +) + +// ParserError is a parse error with range information. +type ParserError struct { + Range parser.Range + Msg string +} + +func (e ParserError) Error() string { + return e.Msg +} + +type ParseResult struct { + Value Program + Errors []ParserError +} + +type errorListener struct { + antlr.DefaultErrorListener + Errors []ParserError +} + +func (l *errorListener) SyntaxError(_ antlr.Recognizer, offendingSymbol any, startL, startC int, msg string, _ antlr.RecognitionException) { + length := 1 + if token, ok := offendingSymbol.(antlr.Token); ok { + length = len(token.GetText()) + } + endL := startL + endC := startC + length - 1 + l.Errors = append(l.Errors, ParserError{ + Msg: msg, + Range: parser.Range{ + Start: parser.Position{Character: startC, Line: startL - 1}, + End: parser.Position{Character: endC, Line: endL - 1}, + }, + }) +} + +// Parse parses an IR textual program and returns the AST. +func Parse(input string) ParseResult { + listener := &errorListener{} + + is := antlr.NewInputStream(input) + lexer := antlrParser.NewIRLexer(is) + lexer.RemoveErrorListeners() + lexer.AddErrorListener(listener) + + stream := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel) + + p := antlrParser.NewIRParser(stream) + p.RemoveErrorListeners() + p.AddErrorListener(listener) + + tree := p.Program() + + // On a syntax error ANTLR's recovery leaves partial nodes behind (a call + // without its parens, an assignment without its rhs). Walking those means + // dereferencing tokens that were never matched, so don't: the errors are + // what the caller needs anyway. + if len(listener.Errors) > 0 { + return ParseResult{ + Errors: listener.Errors, + } + } + + return ParseResult{ + Value: buildAST(tree), + } +} + +func tokenToRange(tok antlr.Token) parser.Range { + startL := tok.GetLine() - 1 + startC := tok.GetColumn() + endC := startC + len(tok.GetText()) - 1 + return parser.Range{ + Start: parser.Position{Character: startC, Line: startL}, + End: parser.Position{Character: endC, Line: startL}, + } +} + +// ---- AST builder (walks the ANTLR parse tree) ---- + +func buildAST(tree antlrParser.IProgramContext) Program { + if tree == nil { + return Program{} + } + lines := tree.AllLine() + stmts := make([]Stmt, 0, len(lines)) + for _, l := range lines { + if s := buildStmt(l); s != nil { + stmts = append(stmts, s) + } + } + return Program{Stmts: stmts} +} + +func buildStmt(ctx antlrParser.ILineContext) Stmt { + if ctx == nil { + return nil + } + + // label marker + if lm := ctx.LabelMarker(); lm != nil { + return buildLabelMarker(lm) + } + + // instruction + if instr := ctx.Instruction(); instr != nil { + return buildInstruction(instr) + } + + return nil +} + +func buildLabelMarker(ctx antlrParser.ILabelMarkerContext) *LabelStmt { + tok := ctx.LABEL().GetSymbol() + name := tok.GetText()[1:] // strip '#' + return &LabelStmt{ + Range: tokenToRange(tok), + Name: name, + } +} + +func buildInstruction(ctx antlrParser.IInstructionContext) *InstrStmt { + switch c := ctx.(type) { + case *antlrParser.InstrWithDestContext: + return buildInstrWithDest(c) + case *antlrParser.InstrNoDestContext: + return buildInstrNoDest(c) + case *antlrParser.ConstAssignContext: + return buildConstAssign(c) + case *antlrParser.InfixInstrContext: + return buildInfixInstr(c) + case *antlrParser.CompoundAssignInstrContext: + return buildCompoundAssignInstr(c) + } + return nil +} + +func buildInstrWithDest(ctx *antlrParser.InstrWithDestContext) *InstrStmt { + dest := buildDest(ctx.Dest()) + call := buildInstrCall(ctx.InstrCall()) + rng := mergeRanges(dest.Range, call.Range) + return &InstrStmt{ + Range: rng, + Dest: &dest, + Call: &call, + } +} + +func buildInstrNoDest(ctx *antlrParser.InstrNoDestContext) *InstrStmt { + call := buildInstrCall(ctx.InstrCall()) + return &InstrStmt{ + Range: call.Range, + Call: &call, + } +} + +func buildConstAssign(ctx *antlrParser.ConstAssignContext) *InstrStmt { + dest := buildDest(ctx.Dest()) + c := buildConst(ctx.Const_()) + rng := mergeRanges(dest.Range, c.Range) + return &InstrStmt{ + Range: rng, + Dest: &dest, + Const: &c, + } +} + +func buildInfixInstr(ctx *antlrParser.InfixInstrContext) *InstrStmt { + dest := buildDest(ctx.Dest()) + left := buildRegRef(ctx.GetLeft()) + right := buildRegRef(ctx.GetRight()) + op := ctx.GetOp().GetText() + rng := mergeRanges(dest.Range, right.Range) + return &InstrStmt{ + Range: rng, + Dest: &dest, + Infix: &Infix{ + Range: rng, + Op: op, + Left: left, + Right: right, + }, + } +} + +func buildCompoundAssignInstr(ctx *antlrParser.CompoundAssignInstrContext) *InstrStmt { + left := buildRegRef(ctx.GetLeft()) + right := buildRegRef(ctx.GetRight()) + op := ctx.GetOp().GetText() + // strip the trailing '=' + infixOp := op[:len(op)-1] + rng := mergeRanges(left.Range, right.Range) + return &InstrStmt{ + Range: rng, + Dest: &Dest{Kind: DestReg, Regs: []RegRef{left}, Range: left.Range}, + CompoundAssign: &Infix{ + Range: rng, + Op: infixOp, + Left: left, + Right: right, + }, + } +} + +func buildDest(ctx antlrParser.IDestContext) Dest { + switch d := ctx.(type) { + case *antlrParser.DestRegContext: + reg := buildRegRef(d.Reg()) + return Dest{Kind: DestReg, Regs: []RegRef{reg}, Range: reg.Range} + case *antlrParser.DestDiscardContext: + tok := d.UNDERSCORE().GetSymbol() + return Dest{Kind: DestDiscard, Range: tokenToRange(tok)} + case *antlrParser.DestListContext: + regs := buildRegList(d.RegList()) + if len(regs) == 0 { + return Dest{Kind: DestList, Range: tokenToRange(d.LBRACKET().GetSymbol())} + } + rng := mergeRanges( + tokenToRange(d.LBRACKET().GetSymbol()), + tokenToRange(d.RBRACKET().GetSymbol()), + ) + return Dest{Kind: DestList, Regs: regs, Range: rng} + } + return Dest{} +} + +func buildRegList(ctx antlrParser.IRegListContext) []RegRef { + if ctx == nil { + return nil + } + allRegs := ctx.AllReg() + regs := make([]RegRef, len(allRegs)) + for i, r := range allRegs { + regs[i] = buildRegRef(r) + } + return regs +} + +func buildInstrCall(ctx antlrParser.IInstrCallContext) InstrCall { + nameCtx := ctx.InstrName() + name, typeParam := buildInstrName(nameCtx) + args := buildArgs(ctx.Args()) + + rngStart := tokenToRange(ctx.LPAREN().GetSymbol()) + rngEnd := tokenToRange(ctx.RPAREN().GetSymbol()) + + return InstrCall{ + Range: mergeRanges(rngStart, rngEnd), + Name: name, + TypeParam: typeParam, + Args: args, + } +} + +func buildInstrName(ctx antlrParser.IInstrNameContext) (name string, typeParam string) { + name = ctx.IDENTIFIER().GetText() + if tn := ctx.TypeName(); tn != nil { + typeParam = tn.(*antlrParser.TypeNameContext).TYPE_KEYWORD().GetText() + } + return +} + +func buildArgs(ctx antlrParser.IArgsContext) []Arg { + if ctx == nil { + return nil + } + allArgs := ctx.AllArg() + args := make([]Arg, len(allArgs)) + for i, a := range allArgs { + args[i] = buildArg(a) + } + return args +} + +func buildArg(ctx antlrParser.IArgContext) Arg { + switch a := ctx.(type) { + case *antlrParser.PositionalArgContext: + val := buildValue(a.Value()) + return Arg{Range: val.Range, Value: val} + case *antlrParser.LabeledArgContext: + label := a.IDENTIFIER().GetText() + val := buildValue(a.Value()) + rng := mergeRanges(tokenToRange(a.IDENTIFIER().GetSymbol()), val.Range) + return Arg{Range: rng, Label: label, Value: val} + } + return Arg{} +} + +func buildValue(ctx antlrParser.IValueContext) Value { + switch v := ctx.(type) { + case *antlrParser.ValRegContext: + reg := buildRegRef(v.Reg()) + return Value{Range: reg.Range, Kind: ValReg, Reg: ®} + case *antlrParser.ValLabelContext: + tok := v.LABEL().GetSymbol() + name := tok.GetText()[1:] // strip '#' + return Value{Range: tokenToRange(tok), Kind: ValLabel, Label: &name} + case *antlrParser.ValIntContext: + tok := v.INT().GetSymbol() + s := tok.GetText() + return Value{Range: tokenToRange(tok), Kind: ValInt, Int: &s} + case *antlrParser.ValRegListContext: + regs := buildRegList(v.RegList()) + lTok := v.LBRACKET().GetSymbol() + rTok := v.RBRACKET().GetSymbol() + rng := mergeRanges(tokenToRange(lTok), tokenToRange(rTok)) + return Value{Range: rng, Kind: ValRegList, Regs: ®s} + } + return Value{} +} + +func buildConst(ctx antlrParser.IConst_Context) Const { + switch c := ctx.(type) { + case *antlrParser.ConstStringContext: + tok := c.STRING().GetSymbol() + raw := tok.GetText() + // strip surrounding quotes + s, err := strconv.Unquote(raw) + if err != nil { + s = raw[1 : len(raw)-1] + } + return Const{Range: tokenToRange(tok), Kind: ConstString, StrVal: &s} + case *antlrParser.ConstIntContext: + tok := c.INT().GetSymbol() + s := tok.GetText() + return Const{Range: tokenToRange(tok), Kind: ConstInt, IntVal: &s} + case *antlrParser.ConstBoolContext: + tok := c.BOOL().GetSymbol() + b := tok.GetText() == "true" + return Const{Range: tokenToRange(tok), Kind: ConstBool, BoolVal: &b} + } + return Const{} +} + +func buildRegRef(ctx antlrParser.IRegContext) RegRef { + tok := ctx.REG().GetSymbol() + name := tok.GetText() + return RegRef{Range: tokenToRange(tok), Name: name} +} + +func mergeRanges(a, b parser.Range) parser.Range { + return parser.Range{Start: a.Start, End: b.End} +} diff --git a/internal/ir/mark_test.go b/internal/ir/mark_test.go new file mode 100644 index 00000000..eee0a282 --- /dev/null +++ b/internal/ir/mark_test.go @@ -0,0 +1,83 @@ +package ir + +import ( + "testing" + + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +// The mark ops carry no register, so there is nothing for the typechecker to get +// wrong — and, more to the point, no operand a caller could use to name a queue +// depth the run-state never marked. What does need checking about them (that pushes +// and ends balance, and that no send or asset change sits inside a region) is a +// control-flow property, left to run time until a verifier pass exists. +func TestMark_Typecheck(t *testing.T) { + require.NoError(t, Typecheck([]Instr{ + MarkPush{}, + MarkEnd{Rewind: true}, + MarkPush{}, + MarkEnd{Rewind: false}, + })) + + // with no operand there is no such thing as an ill-typed mark op: even an + // unbalanced stream is well-typed, and fails at run time instead + require.NoError(t, Typecheck([]Instr{MarkEnd{Rewind: false}})) + require.NoError(t, Typecheck([]Instr{ + LoadStr{Dest: 0, Value: "USD/2"}, + MarkEnd{Rewind: true}, + })) +} + +// One instruction, two textual names — the same shape as +// assert_leftover / assert_leftover_exact. +func TestMark_Dump(t *testing.T) { + out := Dump([]Instr{ + MarkPush{}, + MarkEnd{Rewind: true}, + MarkEnd{Rewind: false}, + }) + require.Contains(t, out, "mark_push()") + require.Contains(t, out, "mark_rewind()") + require.Contains(t, out, "mark_commit()") +} + +func TestMark_Assemble(t *testing.T) { + prog, err := Assemble([]Instr{ + MarkPush{}, + MarkEnd{Rewind: true}, + MarkEnd{Rewind: false}, + }) + require.NoError(t, err) + // the rewind flag rides in A; there is no register to allocate + require.Equal(t, []vm.Instruction{ + {Opcode: byte(vm.Op_MarkPush), A: 0xFF, B: 0xFF, C: 0xFF}, + {Opcode: byte(vm.Op_MarkEnd), A: 1, B: 0xFF, C: 0xFF}, + {Opcode: byte(vm.Op_MarkEnd), A: 0, B: 0xFF, C: 0xFF}, + }, prog.Instructions) + + // no register is consumed in any bank — the old Op_Snapshot spent a big.Int one + require.Zero(t, prog.MaxRegInt) +} + +func TestMark_ParseRoundTrip(t *testing.T) { + instrs, errs := Parse(` + mark_push() + mark_rewind() + mark_push() + mark_commit() +`) + require.Empty(t, errs) + require.Equal(t, []Instr{ + MarkPush{}, + MarkEnd{Rewind: true}, + MarkPush{}, + MarkEnd{Rewind: false}, + }, instrs) + + // a Dump of the parsed stream parses back to the same program, so the two names + // round-trip through the single instruction + reparsed, errs := Parse(Dump(instrs)) + require.Empty(t, errs) + require.Equal(t, instrs, reparsed) +} diff --git a/internal/ir/parse.go b/internal/ir/parse.go new file mode 100644 index 00000000..32251240 --- /dev/null +++ b/internal/ir/parse.go @@ -0,0 +1,600 @@ +package ir + +import ( + "fmt" + "math/big" + "strconv" + + "github.com/formancehq/numscript/internal/ir/internal/syntax" + "github.com/formancehq/numscript/internal/parser" +) + +// Error is something wrong with an IR text: either the grammar rejected it, or +// it doesn't describe a well-formed instruction stream. +type Error struct { + Range parser.Range + Msg string +} + +func (e Error) Error() string { + return fmt.Sprintf("%d:%d: %s", e.Range.Start.Line+1, e.Range.Start.Character+1, e.Msg) +} + +// Parse reads an IR text into the instruction stream it describes. +// +// It checks the grammar and everything the grammar can't express: that +// instructions exist and take the arguments they were given, that labels resolve +// and are unique, and that jumps go forward. It does not typecheck the registers +// — that's Typecheck. +func Parse(src string) ([]Instr, []Error) { + parsed := syntax.Parse(src) + if len(parsed.Errors) > 0 { + errs := make([]Error, len(parsed.Errors)) + for i, e := range parsed.Errors { + errs[i] = Error{Range: e.Range, Msg: e.Msg} + } + return nil, errs + } + return transform(parsed.Value) +} + +// transformer carries the state shared by the whole transformation. +type transformer struct { + // labelPos maps each label defined in the program to its position, so a jump + // can be checked to both resolve and go forward. + labelPos map[string]int + // stmtPos is the position of the statement being transformed. + stmtPos int + // regByName binds each register name to the logical register it got on its + // first appearance, and nameByReg maps it back for error messages. + regByName map[string]Reg + nameByReg map[Reg]string + nextReg Reg + // written records the registers an instruction has assigned to so far, so a + // read of one that was never written can be reported. + written map[Reg]bool +} + +// regName spells a register the way the text did. +func (t *transformer) regName(r Reg) string { + if name, ok := t.nameByReg[r]; ok { + return name + } + return r.String() +} + +// freshReg allocates a register bound to no name. +func (t *transformer) freshReg() Reg { + r := t.nextReg + t.nextReg++ + return r +} + +// resolveReg returns the logical register a name refers to, allocating one on +// the name's first appearance. +func (t *transformer) resolveReg(rr syntax.RegRef) Reg { + if r, ok := t.regByName[rr.Name]; ok { + return r + } + r := t.freshReg() + t.regByName[rr.Name] = r + t.nameByReg[r] = rr.Name + return r +} + +// transform converts a parsed IR AST into a slice of Instr. +// It returns all instructions and any errors encountered. +func transform(prog syntax.Program) ([]Instr, []Error) { + var instrs []Instr + var errs []Error + + t := &transformer{ + labelPos: map[string]int{}, + regByName: map[string]Reg{}, + nameByReg: map[Reg]string{}, + written: map[Reg]bool{}, + } + // First pass: collect the labels and where they sit. + for pos, stmt := range prog.Stmts { + if ls, ok := stmt.(*syntax.LabelStmt); ok { + if _, seen := t.labelPos[ls.Name]; seen { + errs = append(errs, Error{Range: ls.Range, Msg: fmt.Sprintf("duplicate label #%s", ls.Name)}) + } + t.labelPos[ls.Name] = pos + } + } + + for pos, stmt := range prog.Stmts { + switch s := stmt.(type) { + case *syntax.LabelStmt: + instrs = append(instrs, LabelMarker{Label: Label(s.Name)}) + case *syntax.InstrStmt: + t.stmtPos = pos + instr, err := t.transformInstr(s) + if err != nil { + errs = append(errs, *err) + continue + } + + // Jumps only go forward, so text order is execution order: a read with + // no earlier write can't be reached by any path. + for _, r := range instr.sources() { + if !t.written[r] { + errs = append(errs, Error{ + Range: s.Range, + Msg: fmt.Sprintf("register %s is read but never written", t.regName(r)), + }) + } + } + for _, r := range instr.dests() { + t.written[r] = true + } + + instrs = append(instrs, instr) + } + } + + if len(errs) > 0 { + return instrs, errs + } + return instrs, nil +} + +func (t *transformer) transformInstr(s *syntax.InstrStmt) (Instr, *Error) { + switch { + case s.Const != nil: + return t.transformConst(s) + case s.Call != nil: + return t.transformCall(s) + case s.Infix != nil: + return t.transformInfix(s, s.Infix, "infix") + case s.CompoundAssign != nil: + return t.transformInfix(s, s.CompoundAssign, "compound assign") + default: + return nil, &Error{Range: s.Range, Msg: "empty instruction"} + } +} + +// ---- const assignment ---- + +func (t *transformer) transformConst(s *syntax.InstrStmt) (Instr, *Error) { + if s.Dest == nil || s.Dest.Kind != syntax.DestReg { + return nil, &Error{Range: s.Range, Msg: "const assignment requires a single register dest"} + } + dest := t.resolveReg(s.Dest.Regs[0]) + + switch s.Const.Kind { + case syntax.ConstString: + return LoadStr{Dest: dest, Value: *s.Const.StrVal}, nil + case syntax.ConstInt: + n, ok := new(big.Int).SetString(*s.Const.IntVal, 10) + if !ok { + return nil, &Error{Range: s.Const.Range, Msg: fmt.Sprintf("invalid integer: %q", *s.Const.IntVal)} + } + return LoadInt{Dest: dest, Value: *n}, nil + case syntax.ConstBool: + return ConstBool{Dest: dest, Value: *s.Const.BoolVal}, nil + default: + return nil, &Error{Range: s.Range, Msg: "unknown const kind"} + } +} + +// ---- infix / compound assign ---- + +// transformInfix handles both `$d = $l + $r` and `$d += $r`: the parser gives +// the compound form the same shape, with left repeated as the dest. +func (t *transformer) transformInfix(s *syntax.InstrStmt, infix *syntax.Infix, what string) (Instr, *Error) { + if s.Dest == nil || s.Dest.Kind != syntax.DestReg { + return nil, &Error{Range: s.Range, Msg: what + " requires a single register dest"} + } + dest := t.resolveReg(s.Dest.Regs[0]) + left := t.resolveReg(infix.Left) + right := t.resolveReg(infix.Right) + + var op BinKind + switch infix.Op { + case "+": + op = OpAddInt{} + case "-": + op = OpSubInt{} + default: + return nil, &Error{Range: infix.Range, Msg: fmt.Sprintf("unknown infix operator: %q", infix.Op)} + } + return BinaryOp{Op: op, Dest: dest, Left: left, Right: right}, nil +} + +// ---- call instructions ---- + +// argParser reads the args of one instruction call. Accessors report a bad arg +// themselves and return a zero value, so callers read args straight into an Instr +// literal and transformCall checks ap.errs once at the end. Composite literal +// operands evaluate left to right, so `f{a: ap.reg(), b: ap.reg()}` reads in order. +type argParser struct { + t *transformer + args []syntax.Arg + pos int + seenLabel map[string]bool + errs *[]Error + // callRange is where to point an error about an arg that isn't there. + callRange parser.Range +} + +func (t *transformer) newArgParser(call *syntax.InstrCall, errs *[]Error) *argParser { + return &argParser{ + t: t, + args: call.Args, + seenLabel: map[string]bool{}, + errs: errs, + callRange: call.Range, + } +} + +// next consumes the next positional arg, checking it holds the expected kind. It +// reports missing and mistyped args itself and returns nil in both cases, so a +// caller only reads what it needs and the error shows up in ap.errs. +func (ap *argParser) next(want syntax.ValueKind) *syntax.Value { + if ap.pos >= len(ap.args) { + ap.addErr(ap.callRange, "missing %s argument", valueKindStr(want)) + return nil + } + a := ap.args[ap.pos] + ap.pos++ + if a.Value.Kind != want { + ap.addErr(a.Range, "expected %s, got %s", valueKindStr(want), valueKindStr(a.Value.Kind)) + return nil + } + return &a.Value +} + +// reg consumes the next positional arg as a register. +func (ap *argParser) reg() Reg { + v := ap.next(syntax.ValReg) + if v == nil { + return 0 + } + return ap.t.resolveReg(*v.Reg) +} + +// optLabeledReg consumes an optional labeled arg with the given label as a register. +func (ap *argParser) optLabeledReg(name string) *Reg { + a, ok := ap.labeledArg(name) + if !ok { + return nil + } + if a.Value.Kind != syntax.ValReg { + ap.addErr(a.Range, "labeled arg %q: expected register, got %s", name, valueKindStr(a.Value.Kind)) + return nil + } + r := ap.t.resolveReg(*a.Value.Reg) + return &r +} + +// reqLabeledReg is optLabeledReg for a label the instruction can't do without. +func (ap *argParser) reqLabeledReg(name string) Reg { + r := ap.optLabeledReg(name) + if r == nil { + ap.addErr(ap.callRange, "missing labeled argument %q", name) + return 0 + } + return *r +} + +// labeledArg finds a labeled arg by name. Returns nil if not found. +func (ap *argParser) labeledArg(name string) (*syntax.Arg, bool) { + if ap.seenLabel[name] { + return nil, false + } + for i := ap.pos; i < len(ap.args); i++ { + if ap.args[i].Label == name { + ap.seenLabel[name] = true + return &ap.args[i], true + } + } + // also check already-consumed positional area + for i := 0; i < ap.pos; i++ { + if ap.args[i].Label == name { + ap.seenLabel[name] = true + return &ap.args[i], true + } + } + return nil, false +} + +// labelRef consumes the next positional arg as a label reference. It returns "" +// when the arg is missing or isn't one. +func (ap *argParser) labelRef() Label { + v := ap.next(syntax.ValLabel) + if v == nil { + return "" + } + return Label(*v.Label) +} + +// intLit consumes the next positional arg as an integer literal. +func (ap *argParser) intLit() uint16 { + v := ap.next(syntax.ValInt) + if v == nil { + return 0 + } + n, err := strconv.ParseUint(*v.Int, 10, 16) + if err != nil { + ap.addErr(v.Range, "integer literal out of range (0-65535): %s", *v.Int) + return 0 + } + return uint16(n) +} + +func (ap *argParser) addErr(rng parser.Range, format string, args ...any) { + *ap.errs = append(*ap.errs, Error{Range: rng, Msg: fmt.Sprintf(format, args...)}) +} + +func valueKindStr(k syntax.ValueKind) string { + switch k { + case syntax.ValReg: + return "register" + case syntax.ValLabel: + return "label" + case syntax.ValInt: + return "integer literal" + case syntax.ValRegList: + return "register list" + default: + return "unknown" + } +} + +func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { + var errs []Error + ap := t.newArgParser(s.Call, &errs) + + // A labeled arg is looked up by name, so a repeated one would silently lose + // every occurrence but the first. + seen := map[string]bool{} + for _, a := range s.Call.Args { + if a.Label == "" { + continue + } + if seen[a.Label] { + ap.addErr(a.Range, "duplicate labeled argument %q", a.Label) + } + seen[a.Label] = true + } + + // Resolve dest + var dest Reg + var dests []Reg + if s.Dest != nil { + switch s.Dest.Kind { + case syntax.DestReg: + dest = t.resolveReg(s.Dest.Regs[0]) + case syntax.DestDiscard: + // `_` only exists in the text: desugar it to a fresh register, which + // no other statement can name and nothing reads back. + dest = t.freshReg() + case syntax.DestList: + dests = make([]Reg, len(s.Dest.Regs)) + for i, r := range s.Dest.Regs { + dests[i] = t.resolveReg(r) + } + } + } + + name, typeParam := s.Call.Name, s.Call.TypeParam + + // load_var and meta are the only instructions parameterized by a type. + if typeParam != "" && name != "load_var" && name != "meta" { + return nil, &Error{Range: s.Call.Range, Msg: fmt.Sprintf("%s doesn't take a type parameter", name)} + } + + var instr Instr + + switch name { + case "load_var": + var typ VarType + switch typeParam { + case "int": + typ = VarInt{} + case "str": + typ = VarStr{} + default: + return nil, &Error{Range: s.Call.Range, Msg: fmt.Sprintf("load_var: expected type parameter int or str, got %q", typeParam)} + } + instr = LoadVar{Dest: dest, Typ: typ, Index: ap.intLit()} + + case "meta": + var typ MetaType + switch typeParam { + case "str": + typ = MetaStr{} + case "int": + typ = MetaInt{} + case "portion": + typ = MetaPortion{} + default: + return nil, &Error{Range: s.Call.Range, Msg: fmt.Sprintf("meta: expected type parameter str, int or portion, got %q", typeParam)} + } + instr = MetaVar{Dest: dest, Typ: typ, Account: ap.reg(), Key: ap.reg()} + + case "balance": + instr = FetchBalance{Dest: dest, Account: ap.reg(), Asset: ap.reg()} + + case "monetary_to_string": + instr = ap.BinaryOp(dest, OpMonetaryToString{}) + case "mk_portion": + instr = ap.BinaryOp(dest, OpMakePortion{}) + case "add_int": + instr = ap.BinaryOp(dest, OpAddInt{}) + case "sub_int": + instr = ap.BinaryOp(dest, OpSubInt{}) + case "add_string": + instr = ap.BinaryOp(dest, OpAddString{}) + case "str_eq": + instr = ap.BinaryOp(dest, OpStrEq{}) + case "sub_portion": + instr = ap.BinaryOp(dest, OpSubPortion{}) + case "mul_portion": + instr = ap.BinaryOp(dest, OpMulPortion{}) + case "add_portion": + instr = ap.BinaryOp(dest, OpAddPortion{}) + case "lt_int": + instr = ap.BinaryOp(dest, OpLtInt{}) + case "eq_int": + instr = ap.BinaryOp(dest, OpEqInt{}) + case "lt_portion": + instr = ap.BinaryOp(dest, OpLtPortion{}) + case "eq_portion": + instr = ap.BinaryOp(dest, OpEqPortion{}) + + case "int_copy": + instr = UnaryOp{Dest: dest, Op: OpIntCopy{}, Arg: ap.reg()} + case "portion_copy": + instr = UnaryOp{Dest: dest, Op: OpPortionCopy{}, Arg: ap.reg()} + case "str_copy": + instr = UnaryOp{Dest: dest, Op: OpStrCopy{}, Arg: ap.reg()} + case "bool_copy": + instr = UnaryOp{Dest: dest, Op: OpBoolCopy{}, Arg: ap.reg()} + case "neg_int": + instr = UnaryOp{Dest: dest, Op: OpNegInt{}, Arg: ap.reg()} + case "int_to_string": + instr = UnaryOp{Dest: dest, Op: OpIntToString{}, Arg: ap.reg()} + case "is_zero": + instr = UnaryOp{Dest: dest, Op: OpIsZero{}, Arg: ap.reg()} + case "not": + instr = UnaryOp{Dest: dest, Op: OpNot{}, Arg: ap.reg()} + case "portion_to_string": + instr = UnaryOp{Dest: dest, Op: OpPortionToString{}, Arg: ap.reg()} + case "int_to_portion": + instr = UnaryOp{Dest: dest, Op: OpIntToPortion{}, Arg: ap.reg()} + case "portion_to_int": + instr = UnaryOp{Dest: dest, Op: OpPortionToInt{}, Arg: ap.reg()} + + case "pull_account": + instr = PullAccount{ + Dest: dest, + Account: ap.reqLabeledReg("account"), + Cap: ap.optLabeledReg("cap"), + Overdraft: ap.optLabeledReg("overdraft"), + Color: ap.optLabeledReg("color"), + } + case "send_to_account": + instr = SendToAccount{Account: ap.optLabeledReg("account"), Cap: ap.optLabeledReg("cap")} + case "save": + instr = Save{ + Account: ap.reqLabeledReg("account"), + Asset: ap.reqLabeledReg("asset"), + Amount: ap.optLabeledReg("amount"), + } + + case "meta_monetary": + if len(dests) != 2 { + ap.addErr(s.Range, "meta_monetary requires a dest list of 2 registers (asset, amount)") + break + } + instr = MetaMonetary{ + DestAsset: dests[0], + DestAmount: dests[1], + Account: ap.reg(), + Key: ap.reg(), + } + + case "check_enough_funds": + instr = CheckEnoughFunds{Got: ap.reg(), Needed: ap.reg()} + case "assert_leftover": + instr = AssertLeftover{Portion: ap.reg(), Exact: false} + case "assert_leftover_exact": + instr = AssertLeftover{Portion: ap.reg(), Exact: true} + case "set_current_asset": + instr = SetCurrentAsset{Asset: ap.reg()} + case "assert_same_asset": + instr = AssertSameAsset{Left: ap.reg(), Right: ap.reg()} + case "assert_valid_account": + instr = AssertValidAccount{Account: ap.reg()} + case "assert_valid_color": + instr = AssertValidColor{Color: ap.reg()} + case "assert_non_negative_balance": + instr = AssertNonNegativeBalance{Balance: ap.reg(), Account: ap.reg()} + + // two names for one instruction, as assert_leftover/assert_leftover_exact are + case "mark_push": + instr = MarkPush{} + case "mark_rewind": + instr = MarkEnd{Rewind: true} + case "mark_commit": + instr = MarkEnd{Rewind: false} + + case "set_tx_meta": + instr = SetTxMeta{Key: ap.reg(), Value: ap.reg()} + case "set_account_meta": + instr = SetAccountMeta{Account: ap.reg(), Key: ap.reg(), Value: ap.reg()} + + case "jmp_if_false": + cond, target := ap.reg(), ap.labelRef() + if t.checkJmpTarget(ap, s, name, target) { + instr = JmpIfFalse{Cond: cond, Target: target} + } + + case "jmp_if_true": + cond, target := ap.reg(), ap.labelRef() + if t.checkJmpTarget(ap, s, name, target) { + instr = JmpIfTrue{Cond: cond, Target: target} + } + + case "jmp": + target := ap.labelRef() + if t.checkJmpTarget(ap, s, name, target) { + instr = Jmp{Target: target} + } + + default: + return nil, &Error{Range: s.Call.Range, Msg: fmt.Sprintf("unknown instruction: %s", name)} + } + + // Check for unconsumed args (skip labeled args that were already seen) + for ap.pos < len(ap.args) { + a := ap.args[ap.pos] + if a.Label != "" && ap.seenLabel[a.Label] { + ap.pos++ + continue + } + ap.addErr(a.Range, "unexpected extra argument") + ap.pos++ + } + // Also check labeled args that weren't consumed + for i := range ap.args { + if ap.args[i].Label != "" && !ap.seenLabel[ap.args[i].Label] { + ap.addErr(ap.args[i].Range, "unknown labeled argument %q", ap.args[i].Label) + } + } + + if len(errs) > 0 { + // Return first error along with the instruction + return instr, &errs[0] + } + return instr, nil +} + +// checkJmpTarget reports whether target is a label the jump named name may +// reach: one that is defined, and not behind the jump. The VM only allows +// jumping forward — that's what makes every program terminate — so a backward +// jump is rejected here rather than assembled. +func (t *transformer) checkJmpTarget(ap *argParser, s *syntax.InstrStmt, name string, target Label) bool { + labelPos, defined := t.labelPos[string(target)] + switch { + case target == "": + // labelRef already said what was wrong + return false + case !defined: + ap.addErr(s.Range, "%s: label %s is not defined in the program", name, target) + return false + case labelPos < t.stmtPos: + ap.addErr(s.Range, "%s: label %s is behind the jump (jumps must go forward)", name, target) + return false + default: + return true + } +} + +// BinaryOp reads the two register args every binary instruction takes. +func (ap *argParser) BinaryOp(dest Reg, op BinKind) BinaryOp { + return BinaryOp{Dest: dest, Op: op, Left: ap.reg(), Right: ap.reg()} +} diff --git a/internal/ir/parse_test.go b/internal/ir/parse_test.go new file mode 100644 index 00000000..0b8e4bba --- /dev/null +++ b/internal/ir/parse_test.go @@ -0,0 +1,864 @@ +package ir + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// parseAndDump parses an IR text and re-dumps it. +func parseAndDump(t *testing.T, source string) ([]Instr, string) { + t.Helper() + instrs, errs := Parse(source) + require.Empty(t, errs, "IR errors: %v", errs) + return instrs, "\n" + Dump(instrs) +} + +// TestParseErrors checks what Parse rejects. +func TestParseErrors(t *testing.T) { + t.Run("unknown instruction", func(t *testing.T) { + _, errs := Parse(` + $r0 = no_such_instr($r1) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "unknown instruction") + }) + + t.Run("invalid arg type", func(t *testing.T) { + _, errs := Parse(` + $r0 = neg_int(42) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "expected register") + }) + + t.Run("unbound jmp label", func(t *testing.T) { + _, errs := Parse(` + jmp_if_false($r0, #missing_label) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "not defined") + }) + + t.Run("forward jmp", func(t *testing.T) { + _, errs := Parse(` + $r0 = 0 + jmp_if_false($r0, #my_label) +#my_label +`) + require.Empty(t, errs) + }) + + t.Run("backward jmp", func(t *testing.T) { + _, errs := Parse(` +#my_label + jmp_if_false($r0, #my_label) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "must go forward") + }) + + t.Run("forward unconditional jmp", func(t *testing.T) { + _, errs := Parse(` + jmp(#my_label) +#my_label +`) + require.Empty(t, errs) + }) + + t.Run("backward unconditional jmp", func(t *testing.T) { + _, errs := Parse(` +#my_label + jmp(#my_label) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "must go forward") + }) + + t.Run("unconditional jmp to an undefined label", func(t *testing.T) { + _, errs := Parse(` + jmp(#nope) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "not defined") + }) + + t.Run("missing required labeled arg", func(t *testing.T) { + _, errs := Parse(` + $r0 = "acc" + $r1 = 1 + $r2 = pull_account(cap: $r1) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, `missing labeled argument "account"`) + }) + + t.Run("labeled arg no instruction takes", func(t *testing.T) { + // a stray label on an arg that was consumed positionally: the extra-args + // loop can't see it, so it's the leftover-label check that reports it + _, errs := Parse(` + $r0 = 1 + $r1 = 2 + check_enough_funds(foo: $r0, $r1) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, `unknown labeled argument "foo"`) + }) + + t.Run("labeled arg an instruction can't place", func(t *testing.T) { + _, errs := Parse(` + $r0 = "acc" + $r1 = pull_account(account: $r0, nope: $r0) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "unexpected extra argument") + }) + + t.Run("labels are case sensitive", func(t *testing.T) { + // IDENTIFIER is lowercase-only, so this doesn't even lex + _, errs := Parse(` + $r0 = "acc" + $r1 = pull_account(Account: $r0) +`) + require.NotEmpty(t, errs) + }) + + t.Run("duplicate labeled arg", func(t *testing.T) { + _, errs := Parse(` + $r0 = "acc" + $r1 = 1 + $r2 = pull_account(account: $r0, cap: $r1, cap: $r1) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "duplicate labeled argument") + }) + + t.Run("duplicate label", func(t *testing.T) { + _, errs := Parse(` + jmp_if_false($r0, #l) +#l +#l +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "duplicate label") + }) + + t.Run("const assigned to a dest list", func(t *testing.T) { + _, errs := Parse(` + [$r0, $r1] = 1 +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "const assignment requires a single register dest") + }) + + t.Run("infix assigned to a dest list", func(t *testing.T) { + _, errs := Parse(` + $r0 = 1 + [$r1, $r2] = $r0 + $r0 +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "infix requires a single register dest") + }) + + t.Run("compound assign to a dest list", func(t *testing.T) { + // the grammar only allows a single register left of `+=`, so this one is + // rejected before the transform sees it + _, errs := Parse(` + $r0 = 1 + [$r1, $r2] += $r0 +`) + require.NotEmpty(t, errs) + }) + + t.Run("labeled arg of the wrong kind", func(t *testing.T) { + _, errs := Parse(` + $r0 = pull_account(account: 42) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, `labeled arg "account": expected register, got integer literal`) + }) + + t.Run("register where a label is expected", func(t *testing.T) { + _, errs := Parse(` + $r0 = 1 + jmp_if_false($r0, $r0) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "expected label, got register") + }) + + t.Run("register list where a register is expected", func(t *testing.T) { + _, errs := Parse(` + $r0 = "USD/2" + set_current_asset([$r0, $r0]) +`) + require.NotEmpty(t, errs) + require.Contains(t, errs[0].Msg, "expected register, got register list") + }) +} + +// The infix forms are sugar: the call form of every binary op parses too, even +// though ir.Dump never prints it for add_int / sub_int. +func TestBinaryOpCallForms(t *testing.T) { + instrs, errs := Parse(` + $i = 1 + $s = "x" + $p = mk_portion($i, $i) + $sum = add_int($i, $i) + $diff = sub_int($i, $i) + $cat = add_string($s, $s) + $rest = sub_portion($p, $p) + $tot = add_portion($p, $p) + $label = monetary_to_string($s, $i) +`) + require.Empty(t, errs) + require.NoError(t, Typecheck(instrs)) + + // the dump switches the two int ops back to their infix spelling + require.Contains(t, Dump(instrs), "$r3 = $r0 + $r0") + require.Contains(t, Dump(instrs), "$r4 = $r0 - $r0") + require.Contains(t, Dump(instrs), "$r5 = add_string($r1, $r1)") + // only the int ops have infix sugar: `+` and `-` bind to add_int/sub_int, and + // ir.Parse doesn't typecheck, so it couldn't dispatch on operand type anyway + require.Contains(t, Dump(instrs), "$r6 = sub_portion($r2, $r2)") + require.Contains(t, Dump(instrs), "$r7 = add_portion($r2, $r2)") +} + +func TestParseErrorMessageFormat(t *testing.T) { + _, errs := Parse(` + $r0 = no_such_instr($r1) +`) + require.NotEmpty(t, errs) + // 1-based line:character, then the reason + require.Regexp(t, `^\d+:\d+: .*unknown instruction`, errs[0].Error()) +} + +// TestReadBeforeWrite checks that reading a register nothing ever assigned to is +// rejected, and reported under the name the text used. +func TestReadBeforeWrite(t *testing.T) { + t.Run("never written at all", func(t *testing.T) { + _, errs := Parse(` + $a = 42 + $y = lt_int($a, $b) +`) + require.Len(t, errs, 1) + require.Contains(t, errs[0].Msg, "$b is read but never written") + }) + + t.Run("written only after the read", func(t *testing.T) { + _, errs := Parse(` + $a = 42 + $y = lt_int($a, $b) + $b = 1 +`) + require.Len(t, errs, 1) + require.Contains(t, errs[0].Msg, "$b is read but never written") + }) + + t.Run("compound assign reads its own dest", func(t *testing.T) { + _, errs := Parse(` + $b = 1 + $acc += $b +`) + require.Len(t, errs, 1) + require.Contains(t, errs[0].Msg, "$acc is read but never written") + }) + + t.Run("labeled args are reads too", func(t *testing.T) { + _, errs := Parse(` + $acc = "src" + $pulled = pull_account(account: $acc, cap: $missing) +`) + require.Len(t, errs, 1) + require.Contains(t, errs[0].Msg, "$missing is read but never written") + }) + + t.Run("a dest is written for later instructions", func(t *testing.T) { + _, errs := Parse(` + $a = 1 + $b = 2 + $sum = $a + $b + $twice = $sum + $sum +`) + require.Empty(t, errs) + }) + + t.Run("dest list entries count as written", func(t *testing.T) { + _, errs := Parse(` + $acct = "acct" + $key = "key" + [$asset, $amount] = meta_monetary($acct, $key) + check_enough_funds($amount, $amount) +`) + require.Empty(t, errs) + }) +} + +// TestMalformedInputIsRejected checks that text → Instr reports errors on +// invalid input rather than panicking. It doesn't typecheck: only syntax and +// the structural rules (labels resolve, jumps go forward) are checked here. +func TestMalformedInputIsRejected(t *testing.T) { + sources := []struct { + name string + ir string + }{ + {"comment", "// not a comment in this format\n $r0 = 1\n"}, + {"no args at all", " $r0 = neg_int()\n"}, + {"too few args", " $r0 = balance($r1)\n"}, + {"too many args", " $r0 = neg_int($r1, $r2)\n"}, + {"missing required labeled arg", " $r0 = pull_account(cap: $r1)\n"}, + {"unknown labeled arg", " $r0 = pull_account(account: $r1, nope: $r2)\n"}, + {"capitalised label", " $r0 = pull_account(Account: $r1)\n"}, + {"load_var index out of range", " $r0 = load_var(70000)\n"}, + {"load_var without type param", " $r0 = load_var(0)\n"}, + {"load_var with a type it doesn't have", " $r0 = load_var(0)\n"}, + {"meta without type param", " $r0 = meta($r1, $r2)\n"}, + {"meta_monetary without dest list", " $r0 = meta_monetary($r1, $r2)\n"}, + {"reg to reg copy", " $r0 = $r1\n"}, + {"garbage", "$$$ !!!"}, + {"unclosed paren", " $r0 = neg_int($r1"}, + {"uppercase instr name", " $r0 = NEG_INT($r1)"}, + {"negative int literal", " $r0 = -1\n"}, + {"empty dest list", " [] = meta_monetary($r0, $r1)\n"}, + {"missing dest", " = neg_int($r0)\n"}, + {"unterminated string", " $r0 = \"oops\n"}, + {"stray operator", " $r0 = $r1 * $r2\n"}, + {"type param on plain instr", " $r0 = neg_int($r1)\n"}, + {"label as instr arg", " set_current_asset(#lbl)\n"}, + // a bool is a const, never an operand: no instruction takes one inline + {"bool as instr arg", " set_current_asset(true)\n"}, + {"bool as labeled arg", " $r0 = pull_account(account: false)\n"}, + {"bool in a dest list", " [$r0, $r1] = true\n"}, + {"capitalised bool", " $r0 = True\n"}, + } + + for _, s := range sources { + t.Run(s.name, func(t *testing.T) { + instrs, errs := Parse(s.ir) + require.NotEmpty(t, errs, "neither the parser nor the transform rejected it") + // and whatever it did return must be usable: a nil instruction in the + // stream would blow up in dump or assemble instead + for _, instr := range instrs { + require.NotNil(t, instr) + } + }) + } +} + +// TestRegNamesBindInOrder checks how a name becomes a logical register: the +// first appearance allocates the next one, later appearances reuse it. The name +// itself carries no meaning — `$r` is a convention, not an index. +func TestRegNamesBindInOrder(t *testing.T) { + _, dumped := parseAndDump(t, ` + $asset = "USD/2" + $amount = 10 + $label = monetary_to_string($asset, $amount) + $twice = add_int($amount, $amount) + $r99 = int_to_string($twice) +`) + + require.Equal(t, ` + $r0 = "USD/2" + $r1 = 10 + $r2 = monetary_to_string($r0, $r1) + $r3 = $r1 + $r1 + $r4 = int_to_string($r3) +`, dumped) +} + +// TestDiscardDestDesugarsToFreshReg checks that `_` becomes a register no +// statement can name, and that two discards don't alias — otherwise they'd be +// forced to share a type. +func TestDiscardDestDesugarsToFreshReg(t *testing.T) { + instrs, dumped := parseAndDump(t, ` + $r0 = "acc" + $r1 = "USD/2" + _ = pull_account(account: $r0) + _ = balance($r0, $r1) +`) + + pulled := instrs[2].dests()[0] + balance := instrs[3].dests()[0] + require.NotEqual(t, pulled, balance) + // above every register the text refers to ($r0, $r1) + require.Greater(t, uint(pulled), uint(1)) + require.Greater(t, uint(balance), uint(1)) + + // so the typechecker doesn't see one register written with two types + require.NoError(t, Typecheck(instrs)) + + // the IR has no notion of a discard: it dumps as the register it desugared to + require.Equal(t, ` + $r0 = "acc" + $r1 = "USD/2" + $r2 = pull_account(account: $r0) + $r3 = balance($r0, $r1) +`, dumped) +} + +// TestRoundtripAllInstructions tests every instruction in isolation for roundtrip. +func TestRoundtripAllInstructions(t *testing.T) { + tests := []struct { + name string + ir string + }{ + { + name: "LoadStr", + ir: ` + $r0 = "hello" +`, + }, + { + name: "LoadInt", + ir: ` + $r0 = 42 +`, + }, + { + name: "mk_portion", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) +`, + }, + { + name: "add_int via infix", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = $r0 + $r1 +`, + }, + { + name: "infix add", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = $r0 + $r1 +`, + }, + { + name: "compound add", + ir: ` + $r0 = 0 + $r1 = 1 + $r0 += $r1 +`, + }, + { + name: "infix sub", + ir: ` + $r0 = 5 + $r1 = 3 + $r2 = $r0 - $r1 +`, + }, + { + name: "compound sub", + ir: ` + $r0 = 5 + $r1 = 3 + $r0 -= $r1 +`, + }, + { + name: "unary ops", + ir: ` + $r0 = 10 + $r1 = neg_int($r0) + $r2 = int_copy($r0) + $r3 = int_to_string($r0) +`, + }, + { + name: "pull_account with all labeled args", + ir: ` + $r0 = "src" + $r1 = 100 + $r2 = 0 + $r3 = "red" + $r4 = pull_account(account: $r0, cap: $r1, overdraft: $r2, color: $r3) +`, + }, + { + name: "pull_account minimal", + ir: ` + $r0 = "src" + $r1 = pull_account(account: $r0) +`, + }, + { + name: "send_to_account", + ir: ` + $r0 = "dest" + send_to_account(account: $r0) +`, + }, + { + name: "send_to_account with cap", + ir: ` + $r0 = "dest" + $r1 = 50 + send_to_account(account: $r0, cap: $r1) +`, + }, + { + name: "save with amount", + ir: ` + $r0 = "acct" + $r1 = "USD/2" + $r2 = 100 + save(account: $r0, asset: $r1, amount: $r2) +`, + }, + { + name: "save all", + ir: ` + $r0 = "acct" + $r1 = "USD/2" + save(account: $r0, asset: $r1) +`, + }, + { + name: "check_enough_funds", + ir: ` + $r0 = 50 + $r1 = 100 + check_enough_funds($r0, $r1) +`, + }, + { + name: "assert_leftover", + ir: ` + $r0 = 1 + $r1 = 1 + $r2 = mk_portion($r0, $r1) + assert_leftover($r2) +`, + }, + { + name: "assert_leftover_exact", + ir: ` + $r0 = 1 + $r1 = 1 + $r2 = mk_portion($r0, $r1) + assert_leftover_exact($r2) +`, + }, + { + name: "assert_same_asset", + ir: ` + $r0 = "USD/2" + $r1 = "EUR/2" + assert_same_asset($r0, $r1) +`, + }, + { + name: "assert_valid_account", + ir: ` + $r0 = "users:alice" + assert_valid_account($r0) +`, + }, + { + name: "assert_valid_color", + ir: ` + $r0 = "RED" + assert_valid_color($r0) +`, + }, + { + name: "assert_non_negative_balance", + ir: ` + $r0 = 100 + $r1 = "src" + assert_non_negative_balance($r0, $r1) +`, + }, + { + name: "set_tx_meta", + ir: ` + $r0 = "key" + $r1 = "value" + set_tx_meta($r0, $r1) +`, + }, + { + name: "set_account_meta", + ir: ` + $r0 = "acct" + $r1 = "key" + $r2 = "value" + set_account_meta($r0, $r1, $r2) +`, + }, + { + name: "set_current_asset", + ir: ` + $r0 = "USD/2" + set_current_asset($r0) +`, + }, + { + name: "balance", + ir: ` + $r0 = "src" + $r1 = "USD/2" + $r2 = balance($r0, $r1) +`, + }, + { + name: "meta str", + ir: ` + $r0 = "acct" + $r1 = "key" + $r2 = meta($r0, $r1) +`, + }, + { + name: "meta int", + ir: ` + $r0 = "acct" + $r1 = "key" + $r2 = meta($r0, $r1) +`, + }, + { + name: "meta portion", + ir: ` + $r0 = "acct" + $r1 = "key" + $r2 = meta($r0, $r1) +`, + }, + { + name: "meta_monetary", + ir: ` + $r0 = "acct" + $r1 = "key" + [$r2, $r3] = meta_monetary($r0, $r1) +`, + }, + { + name: "mul_portion", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) + $r3 = mul_portion($r2, $r2) +`, + }, + { + name: "int_to_portion", + ir: ` + $r0 = 100 + $r1 = int_to_portion($r0) +`, + }, + { + name: "portion_to_int", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) + $r3 = portion_to_int($r2) +`, + }, + { + name: "load_var int", + ir: ` + $r0 = load_var(0) +`, + }, + { + name: "load_var str", + ir: ` + $r0 = load_var(1) +`, + }, + { + name: "mark push, rewind and commit", + ir: ` + mark_push() + mark_rewind() + mark_push() + mark_commit() +`, + }, + { + name: "jmp_if_false and label", + ir: ` + $r0 = true + jmp_if_false($r0, #my_label) +#my_label +`, + }, + { + name: "jmp_if_true and label", + ir: ` + $r0 = false + jmp_if_true($r0, #my_label) +#my_label +`, + }, + { + name: "is_zero", + ir: ` + $r0 = 1 + $r1 = is_zero($r0) +`, + }, + { + name: "jmp and label", + ir: ` + jmp(#my_label) +#my_label +`, + }, + { + name: "str_eq", + ir: ` + $r0 = "a" + $r1 = "b" + $r2 = str_eq($r0, $r1) +`, + }, + { + name: "sub_int via infix", + ir: ` + $r0 = 5 + $r1 = 3 + $r2 = $r0 - $r1 +`, + }, + { + name: "add_string", + ir: ` + $r0 = "hello" + $r1 = "world" + $r2 = add_string($r0, $r1) +`, + }, + { + name: "lt_int", + ir: ` + $r0 = 10 + $r1 = 5 + $r2 = lt_int($r0, $r1) +`, + }, + { + name: "eq_int", + ir: ` + $r0 = 10 + $r1 = 5 + $r2 = eq_int($r0, $r1) +`, + }, + { + name: "add_portion", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) + $r3 = add_portion($r2, $r2) +`, + }, + { + name: "sub_portion", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) + $r3 = sub_portion($r2, $r2) +`, + }, + { + name: "lt_portion", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) + $r3 = lt_portion($r2, $r2) +`, + }, + { + name: "eq_portion", + ir: ` + $r0 = 1 + $r1 = 2 + $r2 = mk_portion($r0, $r1) + $r3 = eq_portion($r2, $r2) +`, + }, + { + name: "not", + ir: ` + $r0 = true + $r1 = not($r0) +`, + }, + { + name: "portion_copy", + ir: ` + $r0 = 1 + $r1 = 1 + $r2 = mk_portion($r0, $r1) + $r3 = portion_copy($r2) +`, + }, + { + name: "str_copy", + ir: ` + $r0 = "USD/2" + $r1 = str_copy($r0) +`, + }, + { + name: "bool_copy", + ir: ` + $r0 = true + $r1 = bool_copy($r0) +`, + }, + { + name: "portion_to_string", + ir: ` + $r0 = 1 + $r1 = 1 + $r2 = mk_portion($r0, $r1) + $r3 = portion_to_string($r2) +`, + }, + { + name: "monetary_to_string", + ir: ` + $r0 = "USD/2" + $r1 = 100 + $r2 = monetary_to_string($r0, $r1) +`, + }, + { + name: "bool true", + ir: ` + $r0 = true +`, + }, + { + name: "bool false", + ir: ` + $r0 = false +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // source already has leading newline and indentation + source := tt.ir + _, roundtripped := parseAndDump(t, source) + require.Equal(t, source, roundtripped) + }) + } +} diff --git a/internal/ir/typecheck.go b/internal/ir/typecheck.go new file mode 100644 index 00000000..2a28823d --- /dev/null +++ b/internal/ir/typecheck.go @@ -0,0 +1,261 @@ +package ir + +import "fmt" + +// regType is the type of a virtual register. It mirrors the VM register banks; +// every register has exactly one type for its whole life. A monetary is not one +// of them: it is a (regStr asset, regInt amount) pair. +type regType int + +const ( + regInt regType = iota + regStr + regPortion + regBool +) + +func (t regType) String() string { + switch t { + case regInt: + return "int" + case regStr: + return "string" + case regPortion: + return "portion" + case regBool: + return "bool" + default: + return "?" + } +} + +// bytecodeTypechecker validates an IR stream one instruction at a time, +// remembering the type each register was written with. A later read of that +// register with a different type, or a read before any write, is a bug in whatever +// produced the instructions. +type bytecodeTypechecker struct { + types map[Reg]regType +} + +func newBytecodeTypechecker() *bytecodeTypechecker { + return &bytecodeTypechecker{types: map[Reg]regType{}} +} + +// use asserts r was already written with type want. +func (tc *bytecodeTypechecker) use(r Reg, want regType) error { + got, ok := tc.types[r] + if !ok { + return fmt.Errorf("register %s read as %s before being written", r, want) + } + if got != want { + return fmt.Errorf("register %s read as %s but holds %s", r, want, got) + } + return nil +} + +func (tc *bytecodeTypechecker) useOpt(r *Reg, want regType) error { + if r == nil { + return nil + } + return tc.use(*r, want) +} + +// def records that r now holds type t, rejecting a write that changes its type. +func (tc *bytecodeTypechecker) def(r Reg, t regType) error { + if got, ok := tc.types[r]; ok && got != t { + return fmt.Errorf("register %s written as %s but already holds %s", r, t, got) + } + tc.types[r] = t + return nil +} + +// check typechecks a single instruction, updating the state on success. +func (tc *bytecodeTypechecker) check(instr Instr) error { + switch i := instr.(type) { + case LoadInt: + return tc.def(i.Dest, regInt) + case LoadStr: + return tc.def(i.Dest, regStr) + case ConstBool: + return tc.def(i.Dest, regBool) + case LoadVar: + t, err := varRegType(i.Typ) + if err != nil { + return err + } + return tc.def(i.Dest, t) + + case UnaryOp: + dest, arg, err := unOpRegTypes(i.Op) + if err != nil { + return err + } + return firstErr(tc.use(i.Arg, arg), tc.def(i.Dest, dest)) + case BinaryOp: + dest, left, right, err := binOpRegTypes(i.Op) + if err != nil { + return err + } + return firstErr(tc.use(i.Left, left), tc.use(i.Right, right), tc.def(i.Dest, dest)) + + case PullAccount: + return firstErr( + tc.use(i.Account, regStr), + tc.useOpt(i.Cap, regInt), + tc.useOpt(i.Overdraft, regInt), + tc.useOpt(i.Color, regStr), + tc.def(i.Dest, regInt), + ) + case SendToAccount: + return firstErr(tc.useOpt(i.Account, regStr), tc.useOpt(i.Cap, regInt)) + case Save: + return firstErr(tc.use(i.Account, regStr), tc.use(i.Asset, regStr), tc.useOpt(i.Amount, regInt)) + + case CheckEnoughFunds: + return firstErr(tc.use(i.Got, regInt), tc.use(i.Needed, regInt)) + case AssertLeftover: + return tc.use(i.Portion, regPortion) + case SetCurrentAsset: + return tc.use(i.Asset, regStr) + case AssertSameAsset: + return firstErr(tc.use(i.Left, regStr), tc.use(i.Right, regStr)) + case AssertValidAccount: + return tc.use(i.Account, regStr) + case AssertValidColor: + return tc.use(i.Color, regStr) + case AssertNonNegativeBalance: + return firstErr(tc.use(i.Balance, regInt), tc.use(i.Account, regStr)) + + case SetTxMeta: + return firstErr(tc.use(i.Key, regStr), tc.use(i.Value, regStr)) + case SetAccountMeta: + return firstErr(tc.use(i.Account, regStr), tc.use(i.Key, regStr), tc.use(i.Value, regStr)) + case MetaVar: + t, err := metaRegType(i.Typ) + if err != nil { + return err + } + return firstErr(tc.use(i.Account, regStr), tc.use(i.Key, regStr), tc.def(i.Dest, t)) + case MetaMonetary: + return firstErr( + tc.use(i.Account, regStr), + tc.use(i.Key, regStr), + tc.def(i.DestAsset, regStr), + tc.def(i.DestAmount, regInt), + ) + case FetchBalance: + return firstErr(tc.use(i.Account, regStr), tc.use(i.Asset, regStr), tc.def(i.Dest, regInt)) + + case JmpIfFalse: + return tc.use(i.Cond, regBool) + case JmpIfTrue: + return tc.use(i.Cond, regBool) + case Jmp: + return nil + case LabelMarker: + return nil + + // the mark ops touch no register. What does need checking about them is a + // control-flow property, not a register one; see the note on MarkPush in instr.go + case MarkPush, MarkEnd: + return nil + + default: + return fmt.Errorf("bytecode typechecker: unhandled instruction %T", instr) + } +} + +func Typecheck(instrs []Instr) error { + tc := newBytecodeTypechecker() + for pos, instr := range instrs { + if err := tc.check(instr); err != nil { + return fmt.Errorf("at instruction %d (%s): %w", pos, instr, err) + } + } + return nil +} + +func firstErr(errs ...error) error { + for _, e := range errs { + if e != nil { + return e + } + } + return nil +} + +func varRegType(t VarType) (regType, error) { + switch t.(type) { + case VarInt: + return regInt, nil + case VarStr: + return regStr, nil + default: + return 0, fmt.Errorf("bytecode typechecker: unknown var type %T", t) + } +} + +func metaRegType(t MetaType) (regType, error) { + switch t.(type) { + case MetaStr: + return regStr, nil + case MetaInt: + return regInt, nil + case MetaPortion: + return regPortion, nil + default: + return 0, fmt.Errorf("bytecode typechecker: unknown meta type %T", t) + } +} + +func unOpRegTypes(op UnKind) (dest, arg regType, err error) { + switch op.(type) { + case OpIntCopy: + return regInt, regInt, nil + case OpPortionCopy: + return regPortion, regPortion, nil + case OpStrCopy: + return regStr, regStr, nil + case OpBoolCopy: + return regBool, regBool, nil + case OpNegInt: + return regInt, regInt, nil + case OpIntToString: + return regStr, regInt, nil + case OpIsZero: + return regBool, regInt, nil + case OpNot: + return regBool, regBool, nil + case OpPortionToString: + return regStr, regPortion, nil + case OpIntToPortion: + return regPortion, regInt, nil + case OpPortionToInt: + return regInt, regPortion, nil + default: + return 0, 0, fmt.Errorf("bytecode typechecker: unknown unary op %T", op) + } +} + +func binOpRegTypes(op BinKind) (dest, left, right regType, err error) { + switch op.(type) { + case OpAddInt, OpSubInt: + return regInt, regInt, regInt, nil + case OpAddString: + return regStr, regStr, regStr, nil + case OpStrEq: + return regBool, regStr, regStr, nil + case OpLtInt, OpEqInt: + return regBool, regInt, regInt, nil + case OpLtPortion, OpEqPortion: + return regBool, regPortion, regPortion, nil + case OpAddPortion, OpSubPortion, OpMulPortion: + return regPortion, regPortion, regPortion, nil + case OpMakePortion: + return regPortion, regInt, regInt, nil + case OpMonetaryToString: + return regStr, regStr, regInt, nil + default: + return 0, 0, 0, fmt.Errorf("bytecode typechecker: unknown binary op %T", op) + } +} diff --git a/internal/ir/typecheck_test.go b/internal/ir/typecheck_test.go new file mode 100644 index 00000000..f26236ac --- /dev/null +++ b/internal/ir/typecheck_test.go @@ -0,0 +1,321 @@ +package ir + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBytecodeTypecheck_Valid(t *testing.T) { + // $0 = 1; $1 = 2; $2 = $0 + $1 (all int) + instrs := []Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + LoadInt{Dest: 1, Value: *big.NewInt(2)}, + BinaryOp{Op: OpAddInt{}, Dest: 2, Left: 0, Right: 1}, + } + require.NoError(t, Typecheck(instrs)) +} + +func TestBytecodeTypecheck_UseBeforeWrite(t *testing.T) { + // reads $0 as int before it is ever written + instrs := []Instr{ + UnaryOp{Op: OpNegInt{}, Dest: 1, Arg: 0}, + } + require.ErrorContains(t, Typecheck(instrs), "read as int before being written") +} + +func TestBytecodeTypecheck_WrongType(t *testing.T) { + // $0 is a string, then used where an int is expected + instrs := []Instr{ + LoadStr{Dest: 0, Value: "USD/2"}, + UnaryOp{Op: OpNegInt{}, Dest: 1, Arg: 0}, + } + require.ErrorContains(t, Typecheck(instrs), "read as int but holds string") +} + +func TestBytecodeTypecheck_RedefinedWithDifferentType(t *testing.T) { + // $0 written as int, then overwritten as string + instrs := []Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + LoadStr{Dest: 0, Value: "x"}, + } + require.ErrorContains(t, Typecheck(instrs), "written as string but already holds int") +} + +func TestBytecodeTypecheck_ErrorLocatesTheInstruction(t *testing.T) { + instrs := []Instr{ + LoadStr{Dest: 0, Value: "src"}, + LoadStr{Dest: 1, Value: "dest"}, + CheckEnoughFunds{Got: 0, Needed: 1}, + } + err := Typecheck(instrs) + require.ErrorContains(t, err, "at instruction 2") + require.ErrorContains(t, err, "check_enough_funds($r0, $r1)") +} + +// Every register operand must be rejected when it names a register of the wrong +// bank. Each case is the prelude plus one instruction with exactly one bad operand. +func TestBytecodeTypecheck_OperandTypes(t *testing.T) { + intReg, strReg, portionReg := Reg(0), Reg(1), Reg(2) + prelude := []Instr{ + LoadInt{Dest: intReg, Value: *big.NewInt(1)}, + LoadStr{Dest: strReg, Value: "USD/2"}, + BinaryOp{Op: OpMakePortion{}, Dest: portionReg, Left: intReg, Right: intReg}, + } + + testCases := []struct { + name string + instr Instr + }{ + {"pull_account account", PullAccount{Dest: 9, Account: intReg}}, + {"pull_account cap", PullAccount{Dest: 9, Account: strReg, Cap: &strReg}}, + {"pull_account overdraft", PullAccount{Dest: 9, Account: strReg, Overdraft: &strReg}}, + {"pull_account color", PullAccount{Dest: 9, Account: strReg, Color: &intReg}}, + {"send_to_account account", SendToAccount{Account: &intReg}}, + {"send_to_account cap", SendToAccount{Account: &strReg, Cap: &strReg}}, + {"save account", Save{Account: intReg, Asset: strReg}}, + {"save asset", Save{Account: strReg, Asset: intReg}}, + {"save amount", Save{Account: strReg, Asset: strReg, Amount: &strReg}}, + {"check_enough_funds got", CheckEnoughFunds{Got: strReg, Needed: intReg}}, + {"check_enough_funds needed", CheckEnoughFunds{Got: intReg, Needed: strReg}}, + {"assert_leftover", AssertLeftover{Portion: intReg}}, + {"set_current_asset", SetCurrentAsset{Asset: intReg}}, + {"assert_same_asset left", AssertSameAsset{Left: intReg, Right: strReg}}, + {"assert_same_asset right", AssertSameAsset{Left: strReg, Right: intReg}}, + {"assert_valid_account", AssertValidAccount{Account: intReg}}, + {"assert_valid_color", AssertValidColor{Color: intReg}}, + {"assert_non_negative_balance balance", AssertNonNegativeBalance{Balance: strReg, Account: strReg}}, + {"assert_non_negative_balance account", AssertNonNegativeBalance{Balance: intReg, Account: intReg}}, + {"set_tx_meta key", SetTxMeta{Key: intReg, Value: strReg}}, + {"set_tx_meta value", SetTxMeta{Key: strReg, Value: intReg}}, + {"set_account_meta account", SetAccountMeta{Account: intReg, Key: strReg, Value: strReg}}, + {"set_account_meta key", SetAccountMeta{Account: strReg, Key: intReg, Value: strReg}}, + {"set_account_meta value", SetAccountMeta{Account: strReg, Key: strReg, Value: intReg}}, + {"meta account", MetaVar{Dest: 9, Account: intReg, Key: strReg, Typ: MetaStr{}}}, + {"meta key", MetaVar{Dest: 9, Account: strReg, Key: intReg, Typ: MetaStr{}}}, + {"meta_monetary account", MetaMonetary{DestAsset: 9, DestAmount: 10, Account: intReg, Key: strReg}}, + {"meta_monetary key", MetaMonetary{DestAsset: 9, DestAmount: 10, Account: strReg, Key: intReg}}, + {"meta_monetary dest asset", MetaMonetary{DestAsset: intReg, DestAmount: 10, Account: strReg, Key: strReg}}, + {"meta_monetary dest amount", MetaMonetary{DestAsset: 9, DestAmount: strReg, Account: strReg, Key: strReg}}, + {"balance account", FetchBalance{Dest: 9, Account: intReg, Asset: strReg}}, + {"balance asset", FetchBalance{Dest: 9, Account: strReg, Asset: intReg}}, + {"balance dest", FetchBalance{Dest: strReg, Account: strReg, Asset: strReg}}, + // a quantity is not a condition: that's the guarantee the bool bank buys + {"jmp_if_false cond", JmpIfFalse{Cond: intReg, Target: "end"}}, + {"jmp_if_true cond", JmpIfTrue{Cond: strReg, Target: "end"}}, + {"is_zero arg", UnaryOp{Op: OpIsZero{}, Dest: 9, Arg: strReg}}, + {"str_eq left", BinaryOp{Op: OpStrEq{}, Dest: 9, Left: intReg, Right: strReg}}, + // each comparison takes its own bank and yields a bool; not takes a bool + {"lt_int left", BinaryOp{Op: OpLtInt{}, Dest: 9, Left: strReg, Right: intReg}}, + {"lt_int right", BinaryOp{Op: OpLtInt{}, Dest: 9, Left: intReg, Right: portionReg}}, + {"eq_int left", BinaryOp{Op: OpEqInt{}, Dest: 9, Left: strReg, Right: intReg}}, + {"lt_portion left", BinaryOp{Op: OpLtPortion{}, Dest: 9, Left: intReg, Right: portionReg}}, + {"eq_portion right", BinaryOp{Op: OpEqPortion{}, Dest: 9, Left: portionReg, Right: intReg}}, + {"not arg", UnaryOp{Op: OpNot{}, Dest: 9, Arg: intReg}}, + {"add_portion left", BinaryOp{Op: OpAddPortion{}, Dest: 9, Left: intReg, Right: portionReg}}, + {"add_portion right", BinaryOp{Op: OpAddPortion{}, Dest: 9, Left: portionReg, Right: intReg}}, + // a copy never crosses banks + {"int_copy arg", UnaryOp{Op: OpIntCopy{}, Dest: 9, Arg: strReg}}, + {"portion_copy arg", UnaryOp{Op: OpPortionCopy{}, Dest: 9, Arg: intReg}}, + {"str_copy arg", UnaryOp{Op: OpStrCopy{}, Dest: 9, Arg: portionReg}}, + {"bool_copy arg", UnaryOp{Op: OpBoolCopy{}, Dest: 9, Arg: intReg}}, + {"unary arg", UnaryOp{Op: OpPortionToString{}, Dest: 9, Arg: intReg}}, + {"mul_portion left", BinaryOp{Op: OpMulPortion{}, Dest: 9, Left: intReg, Right: portionReg}}, + {"mul_portion right", BinaryOp{Op: OpMulPortion{}, Dest: 9, Left: portionReg, Right: intReg}}, + {"int_to_portion arg", UnaryOp{Op: OpIntToPortion{}, Dest: 9, Arg: portionReg}}, + {"portion_to_int arg", UnaryOp{Op: OpPortionToInt{}, Dest: 9, Arg: intReg}}, + {"binary left", BinaryOp{Op: OpAddString{}, Dest: 9, Left: intReg, Right: strReg}}, + {"binary right", BinaryOp{Op: OpAddString{}, Dest: 9, Left: strReg, Right: intReg}}, + {"monetary_to_string asset", BinaryOp{Op: OpMonetaryToString{}, Dest: 9, Left: intReg, Right: intReg}}, + {"monetary_to_string amount", BinaryOp{Op: OpMonetaryToString{}, Dest: 9, Left: strReg, Right: strReg}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Error(t, Typecheck(append(append([]Instr{}, prelude...), tc.instr))) + }) + } +} + +// The dest bank of these instructions comes from a type tag, not from an operand. +// Reading the dest back as an int is what tells the two apart. +func TestBytecodeTypecheck_TaggedDests(t *testing.T) { + str := Reg(0) + prelude := []Instr{LoadStr{Dest: str, Value: "k"}} + + testCases := []struct { + name string + instr Instr + destIsInt bool + }{ + {"load_var", LoadVar{Dest: 9, Typ: VarInt{}}, true}, + {"load_var", LoadVar{Dest: 9, Typ: VarStr{}}, false}, + {"meta", MetaVar{Dest: 9, Account: str, Key: str, Typ: MetaStr{}}, false}, + {"meta", MetaVar{Dest: 9, Account: str, Key: str, Typ: MetaInt{}}, true}, + {"meta", MetaVar{Dest: 9, Account: str, Key: str, Typ: MetaPortion{}}, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // assert_non_negative_balance only accepts an int register + instrs := append(append([]Instr{}, prelude...), tc.instr, + AssertNonNegativeBalance{Balance: 9, Account: str}) + if tc.destIsInt { + require.NoError(t, Typecheck(instrs)) + } else { + require.Error(t, Typecheck(instrs)) + } + }) + } +} + +// A bool register is its own bank: nothing that takes an int accepts one, and it +// can't be rewritten as another type. +func TestBytecodeTypecheck_Bool(t *testing.T) { + t.Run("const_true and const_false define a bool", func(t *testing.T) { + require.NoError(t, Typecheck([]Instr{ + ConstBool{Dest: 0, Value: true}, + ConstBool{Dest: 1, Value: false}, + })) + }) + + t.Run("a bool is not an int", func(t *testing.T) { + err := Typecheck([]Instr{ + ConstBool{Dest: 0, Value: true}, + AssertNonNegativeBalance{Balance: 0, Account: 0}, + }) + require.ErrorContains(t, err, "read as int but holds bool") + }) + + t.Run("an int is not a bool", func(t *testing.T) { + err := Typecheck([]Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + ConstBool{Dest: 0, Value: true}, + }) + require.ErrorContains(t, err, "written as bool but already holds int") + }) + + t.Run("rewriting a bool with the same type is allowed", func(t *testing.T) { + require.NoError(t, Typecheck([]Instr{ + ConstBool{Dest: 0, Value: true}, + ConstBool{Dest: 0, Value: false}, + })) + }) + + // every comparison feeds a jump directly, and `not` composes with all of them + // — which is what makes the derived operators expressible without opcodes + t.Run("comparisons yield branchable bools", func(t *testing.T) { + // operand register per bank, so each op is fed its own type + prelude := []Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + BinaryOp{Op: OpMakePortion{}, Dest: 1, Left: 0, Right: 0}, + LoadStr{Dest: 2, Value: "x"}, + } + ops := map[BinKind]Reg{ + OpLtInt{}: 0, + OpEqInt{}: 0, + OpLtPortion{}: 1, + OpEqPortion{}: 1, + OpStrEq{}: 2, + } + for op, operand := range ops { + t.Run(op.String(), func(t *testing.T) { + require.NoError(t, Typecheck(append(append([]Instr{}, prelude...), + BinaryOp{Op: op, Dest: 9, Left: operand, Right: operand}, + UnaryOp{Op: OpNot{}, Dest: 10, Arg: 9}, + JmpIfTrue{Cond: 9, Target: "end"}, + JmpIfFalse{Cond: 10, Target: "end"}, + LabelMarker{Label: "end"}, + ))) + }) + } + }) + + // is_zero is in the comparison group too, and is the one unary member + t.Run("is_zero yields a branchable bool", func(t *testing.T) { + require.NoError(t, Typecheck([]Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + UnaryOp{Op: OpIsZero{}, Dest: 1, Arg: 0}, + JmpIfTrue{Cond: 1, Target: "end"}, + LabelMarker{Label: "end"}, + })) + }) + + t.Run("a comparison result is not an int", func(t *testing.T) { + err := Typecheck([]Instr{ + LoadInt{Dest: 0, Value: *big.NewInt(1)}, + BinaryOp{Op: OpEqInt{}, Dest: 1, Left: 0, Right: 0}, + AssertNonNegativeBalance{Balance: 1, Account: 0}, + }) + require.ErrorContains(t, err, "read as int but holds bool") + }) +} + +func TestBytecodeTypecheck_LabelMarker(t *testing.T) { + require.NoError(t, Typecheck([]Instr{LabelMarker{Label: "end"}})) + require.Empty(t, LabelMarker{Label: "end"}.dests()) + require.Empty(t, LabelMarker{Label: "end"}.sources()) +} + +// --- An unknown instruction or type tag is a bug in whatever built the stream: +// reported as an error, never panicked. + +type unknownInstr struct{} + +func (unknownInstr) dests() []Reg { return nil } +func (unknownInstr) sources() []Reg { return nil } +func (unknownInstr) assemble(*assembler) error { return nil } +func (unknownInstr) String() string { return "unknown_instr" } + +type unknownUnOp struct{} + +func (unknownUnOp) String() string { return "unknown_un_op" } +func (unknownUnOp) sig() unaryOpSig { return unaryOpSig{} } + +type unknownBinOp struct{} + +func (unknownBinOp) String() string { return "unknown_bin_op" } +func (unknownBinOp) sig() binaryOpSig { return binaryOpSig{} } + +type unknownVarType struct{} + +func (unknownVarType) String() string { return "unknown_var_type" } +func (unknownVarType) assembleLoad(*assembler, Reg, uint16) error { return nil } + +type unknownMetaType struct{} + +func (unknownMetaType) String() string { return "unknown_meta_type" } +func (unknownMetaType) assembleMeta(*assembler, Reg, Reg, Reg) error { return nil } + +func TestBytecodeTypecheck_UnknownTags(t *testing.T) { + str := Reg(0) + prelude := []Instr{LoadStr{Dest: str, Value: "k"}} + + testCases := []struct { + name string + instr Instr + msg string + }{ + {"instruction", unknownInstr{}, "unhandled instruction"}, + {"unary op", UnaryOp{Op: unknownUnOp{}, Dest: 9, Arg: str}, "unknown unary op"}, + {"binary op", BinaryOp{Op: unknownBinOp{}, Dest: 9, Left: str, Right: str}, "unknown binary op"}, + {"var type", LoadVar{Dest: 9, Typ: unknownVarType{}}, "unknown var type"}, + {"meta type", MetaVar{Dest: 9, Account: str, Key: str, Typ: unknownMetaType{}}, "unknown meta type"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.ErrorContains(t, Typecheck(append(append([]Instr{}, prelude...), tc.instr)), tc.msg) + }) + } +} + +func TestRegTypeString(t *testing.T) { + require.Equal(t, "int", regInt.String()) + require.Equal(t, "string", regStr.String()) + require.Equal(t, "portion", regPortion.String()) + require.Equal(t, "bool", regBool.String()) + require.Equal(t, "?", regType(99).String()) + require.Equal(t, "?", regType(42).String()) +} diff --git a/internal/runtime/metadata.go b/internal/runtime/metadata.go new file mode 100644 index 00000000..e7603936 --- /dev/null +++ b/internal/runtime/metadata.go @@ -0,0 +1,53 @@ +package runtime + +import ( + "github.com/formancehq/numscript/internal/utils" +) + +type AccountMetadata = map[string]string +type AccountsMetadata map[string]AccountMetadata + +func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { + return utils.MapGetOrPutDefault(m, account, func() AccountMetadata { + return AccountMetadata{} + }) +} + +func (m AccountsMetadata) DeepClone() AccountsMetadata { + cloned := make(AccountsMetadata) + for account, accountBalances := range m { + for asset, metadataValue := range accountBalances { + clonedAccountBalances := cloned.fetchAccountMetadata(account) + utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string { + return metadataValue + }) + } + } + return cloned +} + +func (m AccountsMetadata) Merge(update AccountsMetadata) { + for acc, accBalances := range update { + cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata { + return AccountMetadata{} + }) + + for curr, amt := range accBalances { + cachedAcc[curr] = amt + } + } +} + +func (m AccountsMetadata) PrettyPrint() string { + header := []string{"Account", "Name", "Value"} + + var rows [][]string + for account, accMetadata := range m { + for name, value := range accMetadata { + row := []string{account, name, value} + rows = append(rows, row) + } + } + + return utils.CsvPretty(header, rows, true) +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 00000000..88b51bc6 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,619 @@ +// Package runtime is the funds engine shared by the VM and the tree-walking +// interpreter: per-(account, scope, asset, color) balances, a FIFO queue of +// funding sources fed by Pull/PullUncapped, and the postings produced by +// Send/SendUncapped. +// +// Balances load lazily. An entry holds only the net delta applied this run until +// an operation needs the absolute value (a bounded pull, a send-all, a balance() +// read); the Store is consulted then and the starting balance folded in. An +// unbounded pull (nil overdraft) makes cap available regardless of balance, so it +// never triggers a fetch — that is what keeps send-from-@world free of Store +// round-trips. Nothing here knows the name "world": callers decide which accounts +// are unbounded and pass a nil overdraft. +// +// Color "" means uncolored. Pull tags queued funds with a color; Send drains only +// matching sources, preserving the position of the rest. +// +// A *RunState is NOT safe for concurrent use. Use one per execution. +// +// Amounts are *big.Int, a mutable reference type, so this package clones values +// it ingests from the Store and amounts it intends to mutate, only mutates +// big.Ints it privately owns (queued source amounts), and never hands out a live +// reference to internal state. +package runtime + +import ( + "errors" + "math/big" +) + +// ErrNoOpenMark is the only error MarkEnd returns. Well-formed bytecode matches +// pushes to ends, so it only surfaces for hand-written IR or a hand-crafted .numb. +var ErrNoOpenMark = errors.New("runtime: no open mark to end") + +// Store supplies the starting balance for an (account, asset, color) triple. +// Implementations should return 0 (or nil, treated as 0) for unknown triples, not +// an error. The returned *big.Int is cloned on ingest, so the Store may reuse it. +type Store interface { + GetBalance(account, asset, color string) (*big.Int, error) +} + +// Posting is aliased as the interpreter's public Posting type, so the json tags +// define the public ledger serialization contract — keep field names and order +// stable. The VM leaves the scope fields empty; the interpreter fills them. +type Posting struct { + Source string `json:"source"` + SourceScope string `json:"sourceScope,omitempty"` + Destination string `json:"destination"` + DestinationScope string `json:"destinationScope,omitempty"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Color string `json:"color,omitempty"` +} + +type ExecutionResult struct { + Postings []Posting `json:"postings"` + Metadata map[string]string `json:"txMeta"` + AccountsMetadata AccountsMetadata `json:"accountsMeta"` +} + +// PairKey identifies a balance slot. Scope is a second dimension of the account +// (scoped accounts hold separate balances); the VM leaves it empty. +type PairKey struct { + Account string + Scope string + Asset string + Color string +} + +// source is a funding entry queued by Pull / PullUncapped. The amount is +// privately owned by the queue and may be mutated in place. +type source struct { + account string + scope string + amount *big.Int + color string +} + +// While baseLoaded is false, amount is the net delta applied this run and the +// Store has not been consulted; once true, the starting balance has been folded +// in and amount is the absolute running balance. +type balanceEntry struct { + amount big.Int + baseLoaded bool +} + +// mark is one open region: the source-queue depth and the posting-list length at +// the matching MarkPush. A rewinding MarkEnd rolls both back to it. +type mark struct { + sources int32 + postings int32 +} + +// The zero value is not usable; call New. +type RunState struct { + store Store + balances map[PairKey]*balanceEntry + sources []source // FIFO: front = index 0 + postings []Posting + currentAsset string + + // marks is the stack of open marks. LIFO, so the innermost open region is the + // last element. + marks []mark +} + +// New creates an empty RunState backed by store. +func New(store Store) *RunState { + return &RunState{ + store: store, + balances: make(map[PairKey]*balanceEntry), + } +} + +// SetCurrentAsset sets the asset used when an operation omits one. +// +// PRE: no mark is open (see HasOpenMark). A rewinding MarkEnd repays queued funds +// into the current asset's balance, so changing the asset mid-region would repay +// into the wrong one. +func (s *RunState) SetCurrentAsset(asset string) { + s.currentAsset = asset +} + +// Reset clears all per-execution state and rebinds the store, retaining +// map/slice capacity so a RunState can be reused across executions. Any mark left +// open by a run that failed mid-region is dropped. +// +// GetPostings returns copies, so a result obtained before Reset stays valid. +func (s *RunState) Reset(store Store) { + s.store = store + clear(s.balances) + s.sources = s.sources[:0] + s.postings = s.postings[:0] + s.marks = s.marks[:0] + s.currentAsset = "" +} + +// Prewarm seeds the balance cache from a bulk fetch, so the lazy per-key +// Store.GetBalance path is never hit for those keys. +// +// Call it once, before any Pull/Send/Save/ForcePosting. Amounts are cloned. A key +// whose base is already loaded is left untouched (the live value wins), so a +// stray double-call cannot clobber computed state; a key that only holds a delta +// has the base folded into it here. +func (s *RunState) Prewarm(balances map[PairKey]*big.Int) { + for key, amount := range balances { + e := s.balances[key] + if e == nil { + e = &balanceEntry{} + s.balances[key] = e + } else if e.baseLoaded { + continue + } + if amount != nil { + e.amount.Add(&e.amount, amount) // fold base into any accumulated delta + } + e.baseLoaded = true + } +} + +// Has reports whether (account, asset, color) already has its starting balance +// loaded. An entry that only holds a delta so far reports false, so a caller +// still fetches and folds in the base. +func (s *RunState) Has(account, scope, asset, color string) bool { + e := s.balances[PairKey{account, scope, asset, color}] + return e != nil && e.baseLoaded +} + +type AccountBalance struct { + Asset string + Color string + Amount *big.Int +} + +// AccountBalances returns copies of every tracked balance entry for account, with +// starting balances folded in so the amounts are absolute. It only reports +// triples already touched this run — it does not enumerate the Store — so an +// account never prewarmed or touched yields an empty slice. +func (s *RunState) AccountBalances(account, scope string) ([]AccountBalance, error) { + var out []AccountBalance + for key, e := range s.balances { + if key.Account == account && key.Scope == scope { + if err := s.loadBase(key, e); err != nil { + return nil, err + } + out = append(out, AccountBalance{ + Asset: key.Asset, + Color: key.Color, + Amount: new(big.Int).Set(&e.amount), + }) + } + } + return out, nil +} + +// GetAccountBalance returns a fresh copy of the balance for (account, asset, +// color), fetching from the Store on first access. +// +// "" is the unset sentinel for asset, meaning "use currentAsset"; a real asset is +// never the empty string. For color, "" is a legitimate value: uncolored. +func (s *RunState) GetAccountBalance(account, scope, asset, color string) (*big.Int, error) { + if asset == "" { + asset = s.currentAsset + } + bal, err := s.absoluteBalance(account, scope, asset, color) + if err != nil { + return nil, err + } + return new(big.Int).Set(bal), nil +} + +// Pull debits up to cap from src's (currentAsset, color) balance, queues the +// pulled amount as a funding source tagged with color, and writes the amount made +// available into out: +// +// overdraft == nil -> unbounded: available = max(0, cap) +// overdraft == b -> available = min(max(0, balance + max(0,b)), max(0, cap)) +// (pass big.NewInt(0) for the "balance only" default) +// +// out is overwritten and may be any addressable *big.Int (e.g. a VM register). +// cap and overdraft are not mutated. The only allocation per call is the queued +// source's own copy of the amount, which must outlive out. +func (s *RunState) Pull(out *big.Int, src string, scope string, cap *big.Int, overdraft *big.Int, color string) error { + if overdraft == nil { + // available = max(0, cap), independent of the balance, so no Store fetch: + // the debit is recorded as a delta and folded in only if later needed. + out.Set(cap) + if out.Sign() < 0 { + out.SetInt64(0) + } + amt := new(big.Int).Set(out) + s.sources = append(s.sources, source{src, scope, amt, color}) + e := s.entryFor(PairKey{src, scope, s.currentAsset, color}) + e.amount.Sub(&e.amount, out) + return nil + } + + currentBal, err := s.absoluteBalance(src, scope, s.currentAsset, color) + if err != nil { + return err + } + + // eff = max(0, currentBal + max(0, overdraft)) + out.Set(currentBal) + if overdraft.Sign() > 0 { + out.Add(out, overdraft) + } + if out.Sign() < 0 { + out.SetInt64(0) + } + // available = min(eff, cap); a cap < eff (incl. negative) wins here and + // is clamped to >= 0 below + if cap.Cmp(out) < 0 { + out.Set(cap) + } + if out.Sign() < 0 { + out.SetInt64(0) + } + + // an independent copy: out stays the caller's, while the queued amount is + // mutated in place by compactAt/Send + amt := new(big.Int).Set(out) + s.sources = append(s.sources, source{src, scope, amt, color}) + + currentBal.Sub(currentBal, out) + return nil +} + +// PullUncapped makes available max(0, balance + max(0, overdraftBound)) of src's +// (currentAsset, color) balance, queuing it only when positive, and writes the +// available amount into out. As in Pull a negative overdraftBound is clamped to 0, +// so it never eats into a positive balance; pass big.NewInt(0) for the "balance +// only" default. overdraftBound is not mutated. +func (s *RunState) PullUncapped(out *big.Int, src string, scope string, overdraftBound *big.Int, color string) error { + currentBal, err := s.absoluteBalance(src, scope, s.currentAsset, color) + if err != nil { + return err + } + + // available = max(0, currentBal + max(0, overdraftBound)) + out.Set(currentBal) + if overdraftBound.Sign() > 0 { + out.Add(out, overdraftBound) + } + if out.Sign() < 0 { + out.SetInt64(0) + } + + if out.Sign() > 0 { + amt := new(big.Int).Set(out) + s.sources = append(s.sources, source{src, scope, amt, color}) + currentBal.Sub(currentBal, out) // debit in place; cache keeps the pointer + } + return nil +} + +// Send drains queued funding sources in FIFO order until cap is satisfied or +// eligible sources run out. Each emitted posting carries the *consumed source's* +// own color. +// +// color == nil -> match anything; one drain may consume and emit funds of +// several colors at once (the interpreter's destination mode) +// color != nil -> only sources whose color == *color are consumed; others are +// skipped and left in place (*color == "" meaning uncolored) +// +// dest == nil is the keep/refund path: the source is credited back and no posting +// is emitted. A partially consumed source's remainder stays at its position. +// +// PRE: no mark is open (see HasOpenMark). Draining consumes sources from the +// front, renumbering the queue and leaving an open mark at the wrong boundary. +func (s *RunState) Send(dest *string, destScope string, cap *big.Int, color *string) error { + cap = new(big.Int).Set(cap) // clone: we decrement it as sources are consumed + asset := s.currentAsset + i := 0 + for cap.Sign() > 0 && i < len(s.sources) { + s.compactAt(i) // merge the run of adjacent same-(account,scope,color) funds at i + src := s.sources[i] + if color != nil && src.color != *color { + i++ // filtered out: skip, leave in place + continue + } + if src.amount.Cmp(cap) >= 0 { + if err := s.credit(dest, destScope, src, asset, cap); err != nil { + return err + } + if diff := new(big.Int).Sub(src.amount, cap); diff.Sign() > 0 { + s.sources[i].amount = diff // remainder stays at this position + } else { + s.removeAt(i) + } + return nil // cap fully satisfied + } + if err := s.credit(dest, destScope, src, asset, src.amount); err != nil { + return err + } + cap.Sub(cap, src.amount) + s.removeAt(i) // do not advance i; the next source shifts into position i + } + return nil +} + +// SendUncapped applies the same color filter as Send: color == nil drains every +// queued source (each posting keeping its own color); color != nil drains only +// matching ones, leaving others in place. +// +// PRE: no mark is open, for the same reason as Send. +func (s *RunState) SendUncapped(dest *string, destScope string, color *string) error { + asset := s.currentAsset + i := 0 + for i < len(s.sources) { + s.compactAt(i) // merge the run of adjacent same-(account,scope,color) funds at i + src := s.sources[i] + if color != nil && src.color != *color { + i++ // filtered out: skip, leave in place + continue + } + if err := s.credit(dest, destScope, src, asset, src.amount); err != nil { + return err + } + s.removeAt(i) + } + return nil +} + +// ForcePosting moves amount from src to dst bypassing the funding queue, for +// movements the queue does not model (e.g. asset-scaling conversions). Unlike Send +// it uses the explicit asset argument, which may differ from the current asset. A +// non-positive amount is a no-op; no balance sufficiency check is performed. +// +// Safe inside a region, unlike Send: it touches no queue entry, so a rewinding +// MarkEnd undoes it completely by reversing the posting. +func (s *RunState) ForcePosting(src, srcScope, dst, dstScope, asset, color string, amount *big.Int) error { + if amount.Sign() <= 0 { + return nil + } + if err := s.addToBalance(src, srcScope, asset, color, new(big.Int).Neg(amount)); err != nil { + return err + } + return s.addPosting(src, srcScope, dst, dstScope, asset, color, amount) // appends the posting and credits dst +} + +// Save implements the numscript `save` statement: it protects funds from being +// pulled later by reducing the (account, asset, color) balance, floored at zero. +// +// amount != nil -> balance = max(0, balance - amount) (PRE: amount >= 0) +// amount == nil -> "save all": a positive balance becomes 0; a negative +// balance is left unchanged (= min(balance, 0)) +func (s *RunState) Save(account, scope, asset, color string, amount *big.Int) error { + cur, err := s.absoluteBalance(account, scope, asset, color) + if err != nil { + return err + } + if amount == nil { + if cur.Sign() <= 0 { + return nil // negative/zero balance left unchanged + } + cur.SetInt64(0) // floor positive to zero + return nil + } + cur.Sub(cur, amount) + if cur.Sign() < 0 { + cur.SetInt64(0) + } + return nil +} + +// --- marks --- +// +// A region lets a caller try a source evaluation and undo it if it did not work +// out — the `oneof` shape: pull from a branch, and if the branch did not cover the +// cap, repay what it pulled and try the next one. +// +// The mark is a source-queue depth that the caller never holds: RunState owns a +// LIFO of them, so a caller cannot name a depth that was never marked, restore one +// out of order, or restore one twice. The queue can therefore never be truncated +// to a bogus depth. +// +// A depth only stays meaningful while nothing consumes the queue from the front +// and the asset a repay lands on is fixed, which Send, SendUncapped and +// SetCurrentAsset document as a precondition. That half is not enforced here; the +// VM checks it at Op_SendToAccount and Op_SetCurrentAsset. +// +// A rewind undoes pulls and postings, so a ForcePosting inside a region is rolled +// back. It does NOT undo queue *consumption*, which is why Send is refused inside +// a region; see the INVARIANT on MarkEnd. +// +// There is no "rewind but keep the mark": a retry closes with rewind and pushes +// again. Pushes and ends therefore match strictly, so "a region left open after a +// rewind" is not a state to detect but one that cannot be encoded. Neither op +// carries an operand, so mark depth is a function of the instruction stream alone +// and an IR verifier could prove balance statically. No such pass exists yet. + +func (s *RunState) HasOpenMark() bool { + return len(s.marks) > 0 +} + +// MarkPush opens a region at the current source-queue depth and posting-list +// length. Allocation-free once the stack has reached its high-water mark, since +// the backing array is retained across Reset. +func (s *RunState) MarkPush() { + s.marks = append(s.marks, mark{ + sources: int32(len(s.sources)), + postings: int32(len(s.postings)), + }) +} + +// MarkEnd closes the innermost region, always popping the mark. +// +// rewind == false commits: whatever was pulled stays queued and whatever was +// posted stays posted. +// +// rewind == true rolls the region back in two parts. Postings emitted since the +// MarkPush are reversed and dropped; each posting records its own asset, so this +// part does not depend on currentAsset. Sources still queued above the mark are +// repaid to the balance they were debited from, then the queue is truncated. +// compactAt may have folded funds, but the fold preserves (account, scope, color), +// so the repay still lands correctly. The two parts are both additive balance +// adjustments, so their order is conventional rather than required. +// +// INVARIANT: no Send may have run inside the region. Reversing a posting credits +// its source back, which is only correct because the queue entry that funded it was +// pulled *inside* the region and is therefore not also repaid by the second loop. A +// Send can consume entries queued *below* the mark, and for those the credit and +// the repay would both apply — inventing money. Hence Op_SendToAccount is refused +// while a mark is open; relaxing that requires undoing queue consumption too. +func (s *RunState) MarkEnd(rewind bool) error { + if len(s.marks) == 0 { + return ErrNoOpenMark + } + m := s.marks[len(s.marks)-1] + s.marks = s.marks[:len(s.marks)-1] + + if !rewind { + return nil + } + + for i := len(s.postings) - 1; i >= int(m.postings); i-- { + p := s.postings[i] + s.subFromBalance(p.Destination, p.DestinationScope, p.Asset, p.Color, p.Amount) + _ = s.addToBalance(p.Source, p.SourceScope, p.Asset, p.Color, p.Amount) + } + s.postings = s.postings[:m.postings] + + for i := int(m.sources); i < len(s.sources); i++ { + src := s.sources[i] + _ = s.addToBalance(src.account, src.scope, s.currentAsset, src.color, src.amount) + } + s.sources = s.sources[:m.sources] + return nil +} + +// GetPostings returns a fresh slice, so callers cannot alter the internal +// length/order. Posting amounts are write-once — addPosting clones on append and +// never mutates an existing posting — so the *big.Int values are shared rather +// than deep-cloned. +func (s *RunState) GetPostings() []Posting { + out := make([]Posting, len(s.postings)) + copy(out, s.postings) + return out +} + +// --- internal helpers --- + +// credit routes a consumed source amount either into a posting (dest != nil) or +// back to the source as a refund (dest == nil). amount is read-only. +func (s *RunState) credit(dest *string, destScope string, src source, asset string, amount *big.Int) error { + if dest != nil { + return s.addPosting(src.account, src.scope, *dest, destScope, asset, src.color, amount) + } else if amount.Sign() > 0 { + // refund the source: consume funding, emit no posting + return s.addToBalance(src.account, src.scope, asset, src.color, amount) + } + return nil +} + +// entryFor creates a fresh zero-delta entry (base not yet loaded) if absent. +func (s *RunState) entryFor(key PairKey) *balanceEntry { + e := s.balances[key] + if e == nil { + e = &balanceEntry{} + s.balances[key] = e + } + return e +} + +// loadBase folds e's starting balance in from the Store on first need, turning a +// delta-only entry into an absolute one. Idempotent once baseLoaded is set. The +// Store is scope-agnostic: scoped balances are seeded via Prewarm, and the VM (the +// only path hitting the Store) never uses scopes. +func (s *RunState) loadBase(key PairKey, e *balanceEntry) error { + if e.baseLoaded { + return nil + } + fromStore, err := s.store.GetBalance(key.Account, key.Asset, key.Color) + if err != nil { + return err + } + if fromStore != nil { + e.amount.Add(&e.amount, fromStore) // absolute = base + accumulated delta + } + e.baseLoaded = true + return nil +} + +// absoluteBalance returns a live pointer into the entry, loading the starting +// balance from the Store on first access. Internal callers may mutate it in place +// to debit/credit; it is never aliased externally. +func (s *RunState) absoluteBalance(account, scope, asset, color string) (*big.Int, error) { + key := PairKey{account, scope, asset, color} + e := s.entryFor(key) + if err := s.loadBase(key, e); err != nil { + return nil, err + } + return &e.amount, nil +} + +// addToBalance never consults the Store: a delta on a not-yet-loaded entry just +// accumulates (folded against the base later, if ever needed), and a delta on a +// loaded entry mutates the absolute balance in place. delta is read-only. The +// error return is kept for call-site symmetry; it is always nil. +func (s *RunState) addToBalance(account, scope, asset, color string, delta *big.Int) error { + e := s.entryFor(PairKey{account, scope, asset, color}) + e.amount.Add(&e.amount, delta) + return nil +} + +// subFromBalance is addToBalance with the sign flipped, so a caller undoing a +// movement does not have to allocate a negated copy of the amount. delta is +// read-only. +func (s *RunState) subFromBalance(account, scope, asset, color string, delta *big.Int) { + e := s.entryFor(PairKey{account, scope, asset, color}) + e.amount.Sub(&e.amount, delta) +} + +// addPosting appends a posting and credits the destination balance. Non-positive +// amounts are ignored. Postings are never merged here: same-source funds are +// coalesced upstream in the queue by compactAt, so a posting can only fuse +// adjacent funds *within* one drain, never across separate sends. amount is cloned +// into the posting. +func (s *RunState) addPosting(src, srcScope, dst, dstScope, asset, color string, amount *big.Int) error { + if amount.Sign() <= 0 { + return nil + } + s.postings = append(s.postings, Posting{ + Source: src, + SourceScope: srcScope, + Destination: dst, + DestinationScope: dstScope, + Asset: asset, + Color: color, + Amount: new(big.Int).Set(amount), + }) + return s.addToBalance(dst, dstScope, asset, color, amount) +} + +// compactAt coalesces the maximal run of funds at index i sharing i's (account, +// scope, color) into s.sources[i], dropping any zero-amount entries it passes, so +// one drain over them yields a single posting. It operates on the queue and never +// on the posting list, so it cannot fuse funds belonging to different sends. The +// fold mutates s.sources[i].amount in place, safe because queued amounts are +// privately owned. +func (s *RunState) compactAt(i int) { + for i+1 < len(s.sources) { + next := s.sources[i+1] + if next.amount.Sign() == 0 { + s.removeAt(i + 1) + continue + } + if next.account != s.sources[i].account || next.scope != s.sources[i].scope || next.color != s.sources[i].color { + return + } + s.sources[i].amount.Add(s.sources[i].amount, next.amount) + s.removeAt(i + 1) + } +} + +// removeAt preserves the order of the remaining sources. +func (s *RunState) removeAt(i int) { + s.sources = append(s.sources[:i], s.sources[i+1:]...) +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 00000000..9dd1d9f6 --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,1103 @@ +package runtime_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/formancehq/numscript/internal/runtime" +) + +// --- test helpers --------------------------------------------------------- + +// mockStore is a Store that returns preset balances and counts how many times +// each (account, asset, color) triple is fetched, so tests can assert +// lazy/cached reads. +type mockStore struct { + balances map[runtime.PairKey]*big.Int + calls map[runtime.PairKey]int +} + +func newMockStore(initial map[runtime.PairKey]int64) *mockStore { + b := make(map[runtime.PairKey]*big.Int, len(initial)) + for k, v := range initial { + b[k] = big.NewInt(v) + } + return &mockStore{balances: b, calls: make(map[runtime.PairKey]int)} +} + +func (m *mockStore) GetBalance(account, asset, color string) (*big.Int, error) { + k := runtime.PairKey{account, "", asset, color} + m.calls[k]++ + if v, ok := m.balances[k]; ok { + return v, nil + } + return new(big.Int), nil // 0 if absent +} + +func (m *mockStore) callCount(account, asset string) int { + return m.calls[runtime.PairKey{account, "", asset, ""}] +} + +const usd = "USD" + +func newRS(initial map[runtime.PairKey]int64) (*runtime.RunState, *mockStore) { + store := newMockStore(initial) + rs := runtime.New(store) + rs.SetCurrentAsset(usd) + return rs, store +} + +func strptr(s string) *string { return &s } + +// accBal reads a balance; the mock store never errors, so a failure is fatal. +func accBal(rs *runtime.RunState, account, scope, asset, color string) *big.Int { + b, err := rs.GetAccountBalance(account, scope, asset, color) + if err != nil { + panic(err) + } + return b +} + +// pull adapts the out-param Pull to a value-returning form for test ergonomics. +func pull(rs *runtime.RunState, src string, cap, overdraft *big.Int, color string) *big.Int { + out := new(big.Int) + _ = rs.Pull(out, src, "", cap, overdraft, color) + return out +} + +// pullUncapped adapts the out-param PullUncapped to a value-returning form. +func pullUncapped(rs *runtime.RunState, src string, overdraftBound *big.Int, color string) *big.Int { + out := new(big.Int) + _ = rs.PullUncapped(out, src, "", overdraftBound, color) + return out +} + +func wantBalance(t *testing.T, rs *runtime.RunState, account string, want int64) { + t.Helper() + if got := accBal(rs, account, "", usd, ""); got.Cmp(big.NewInt(want)) != 0 { + t.Errorf("balance(%s) = %s, want %d", account, got, want) + } +} + +func wantReturn(t *testing.T, label string, got *big.Int, want int64) { + t.Helper() + if got.Cmp(big.NewInt(want)) != 0 { + t.Errorf("%s = %s, want %d", label, got, want) + } +} + +func wantPostings(t *testing.T, rs *runtime.RunState, want []runtime.Posting) { + t.Helper() + got := rs.GetPostings() + mismatch := len(got) != len(want) + for i := 0; !mismatch && i < len(got); i++ { + g, w := got[i], want[i] + if g.Source != w.Source || g.Destination != w.Destination || + g.Asset != w.Asset || g.Color != w.Color || g.Amount.Cmp(w.Amount) != 0 { + mismatch = true + } + } + if mismatch { + t.Errorf("postings mismatch\n got: %s\nwant: %s", fmtPostings(got), fmtPostings(want)) + } +} + +func fmtPostings(ps []runtime.Posting) string { + out := "[" + for _, p := range ps { + out += "{" + p.Source + "->" + p.Destination + " " + p.Amount.String() + " " + p.Asset + if p.Color != "" { + out += " " + p.Color + } + out += "}" + } + return out + "]" +} + +// --- GetAccountBalance / caching ----------------------------------------- + +func TestGetAccountBalance_FetchesFromStore(t *testing.T) { + rs, store := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + wantBalance(t, rs, "A", 100) + if store.callCount("A", usd) != 1 { + t.Errorf("expected 1 store fetch, got %d", store.callCount("A", usd)) + } +} + +func TestGetAccountBalance_EmptyAssetUsesCurrent(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 42}) + if got := accBal(rs, "A", "", "", ""); got.Cmp(big.NewInt(42)) != 0 { + t.Errorf("got %d, want 42 (empty asset should resolve to currentAsset)", got) + } +} + +func TestGetAccountBalance_MissingIsZeroAndCached(t *testing.T) { + rs, store := newRS(nil) + if got := accBal(rs, "ghost", "", usd, ""); got.Cmp(big.NewInt(0)) != 0 { + t.Errorf("missing account = %d, want 0", got) + } + // second read must not re-hit the store even though value is 0 + _ = accBal(rs, "ghost", "", usd, "") + if c := store.callCount("ghost", usd); c != 1 { + t.Errorf("zero balance not cached: store called %d times, want 1", c) + } +} + +func TestCaching_FetchedOnlyOnce(t *testing.T) { + rs, store := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + for i := 0; i < 5; i++ { + accBal(rs, "A", "", usd, "") + } + if c := store.callCount("A", usd); c != 1 { + t.Errorf("store called %d times, want 1", c) + } +} + +func TestCaching_WriteThroughCompounds(t *testing.T) { + // Pull decreases the balance; the next read must see the decreased value + // without consulting the store again. + rs, store := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(30), big.NewInt(0), "") // A -> 70 + wantBalance(t, rs, "A", 70) + pull(rs, "A", big.NewInt(20), big.NewInt(0), "") // A -> 50 + wantBalance(t, rs, "A", 50) + if c := store.callCount("A", usd); c != 1 { + t.Errorf("store consulted %d times across pulls, want 1", c) + } +} + +// --- Pull (bounded) ------------------------------------------------------- + +func TestPull_BoundedClampedByBalance(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + got := pull(rs, "A", big.NewInt(200), big.NewInt(0), "") // min(max(0,100+0),200)=100 + wantReturn(t, "Pull", got, 100) + wantBalance(t, rs, "A", 0) +} + +func TestPull_BoundedClampedByCap(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + got := pull(rs, "A", big.NewInt(30), big.NewInt(0), "") // min(100,30)=30 + wantReturn(t, "Pull", got, 30) + wantBalance(t, rs, "A", 70) +} + +func TestPull_BoundedWithOverdraftBound(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + // eff = max(0, 100+50) = 150 ; available = min(150, 200) = 150 + got := pull(rs, "A", big.NewInt(200), big.NewInt(50), "") + wantReturn(t, "Pull", got, 150) + wantBalance(t, rs, "A", -50) // overdraft used +} + +func TestPull_NegativeCapClampedToZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + got := pull(rs, "A", big.NewInt(-5), big.NewInt(0), "") + wantReturn(t, "Pull", got, 0) + wantBalance(t, rs, "A", 100) +} + +func TestPull_NegativeOverdraftBoundClampedToZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + // bound clamped to 0 -> eff = 100 -> available = min(100, 200) = 100 + got := pull(rs, "A", big.NewInt(200), big.NewInt(-1000), "") + wantReturn(t, "Pull", got, 100) + wantBalance(t, rs, "A", 0) +} + +func TestPull_NegativeStoreBalanceBounded(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: -20}) + // eff = max(0, -20+0) = 0 -> available = min(0, cap) = 0 + got := pull(rs, "A", big.NewInt(50), big.NewInt(0), "") + wantReturn(t, "Pull", got, 0) + wantBalance(t, rs, "A", -20) +} + +func TestPull_WritesIntoOutAndDoesNotAliasQueue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + out := new(big.Int) + _ = rs.Pull(out, "A", "", big.NewInt(60), big.NewInt(0), "") + if out.Cmp(big.NewInt(60)) != 0 { + t.Fatalf("out written = %s, want 60", out) + } + // Mutating out afterwards must not corrupt the queued source (it's a copy). + out.SetInt64(999) + _ = rs.Send(strptr("X"), "", big.NewInt(60), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(60)}, + }) +} + +func TestPull_OutCanBeReused(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 100}) + out := new(big.Int) + _ = rs.Pull(out, "A", "", big.NewInt(30), big.NewInt(0), "") + if out.Cmp(big.NewInt(30)) != 0 { + t.Fatalf("first = %s, want 30", out) + } + _ = rs.Pull(out, "B", "", big.NewInt(45), big.NewInt(0), "") // same buffer + if out.Cmp(big.NewInt(45)) != 0 { + t.Fatalf("second = %s, want 45", out) + } + // both pulls landed in the queue independently + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(30)}, + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(45)}, + }) +} + +func TestPull_DoesNotMutateCapOrOverdraft(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 10}) + cap := big.NewInt(200) + ovd := big.NewInt(50) + out := new(big.Int) + _ = rs.Pull(out, "A", "", cap, ovd, "") // eff = 10+50 = 60 < 200 -> available 60 + if out.Cmp(big.NewInt(60)) != 0 { + t.Errorf("available = %s, want 60", out) + } + if cap.Cmp(big.NewInt(200)) != 0 { + t.Errorf("cap mutated: %s", cap) + } + if ovd.Cmp(big.NewInt(50)) != 0 { + t.Errorf("overdraft mutated: %s", ovd) + } +} + +// --- Pull (unbounded) ----------------------------------------------------- + +func TestPull_UnboundedTakesFullCap(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 30}) + got := pull(rs, "A", big.NewInt(100), nil, "") + wantReturn(t, "Pull", got, 100) + wantBalance(t, rs, "A", -70) // balance can go negative +} + +func TestPull_UnboundedNegativeCapClampedToZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 30}) + got := pull(rs, "A", big.NewInt(-10), nil, "") + wantReturn(t, "Pull", got, 0) + wantBalance(t, rs, "A", 30) +} + +// --- PullUncapped --------------------------------------------------------- + +func TestPullUncapped_Basic(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + got := pullUncapped(rs, "A", big.NewInt(0), "") + wantReturn(t, "PullUncapped", got, 100) + wantBalance(t, rs, "A", 0) +} + +func TestPullUncapped_WithOverdraft(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + got := pullUncapped(rs, "A", big.NewInt(50), "") + wantReturn(t, "PullUncapped", got, 150) + wantBalance(t, rs, "A", -50) +} + +func TestPullUncapped_WritesIntoOutAndDoesNotAliasQueue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + out := new(big.Int) + _ = rs.PullUncapped(out, "A", "", big.NewInt(0), "") + if out.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("out = %s, want 100", out) + } + out.SetInt64(999) // mutate after: queued source must be an independent copy + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}, + }) +} + +func TestPullUncapped_ZeroNotQueuedNorDebited(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 0}) + got := pullUncapped(rs, "A", big.NewInt(0), "") + wantReturn(t, "PullUncapped", got, 0) + wantBalance(t, rs, "A", 0) + // nothing queued -> a subsequent drain produces no postings + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +func TestPullUncapped_NegativeOverdraftBoundClamped(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 10}) + // bound clamped to 0 -> effective = max(0, 10+0) = 10 + got := pullUncapped(rs, "A", big.NewInt(-50), "") + wantReturn(t, "PullUncapped", got, 10) + wantBalance(t, rs, "A", 0) +} + +func TestPullUncapped_NegativeEffectiveNotQueued(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: -5}) + got := pullUncapped(rs, "A", big.NewInt(0), "") // max(0, -5+0) = 0 + wantReturn(t, "PullUncapped", got, 0) + wantBalance(t, rs, "A", -5) + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- Send: FIFO, partial requeue, posting creation ----------------------- + +func TestSend_PartialConsumeRequeuesFront(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 50}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") // source A:100 + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") // source B:50 + + _ = rs.Send(strptr("X"), "", big.NewInt(30), nil) // takes 30 from A, requeues A:70 at front + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(30)}}) + + _ = rs.Send(strptr("Y"), "", big.NewInt(200), nil) // A:70 then B:50, both fully + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(30)}, + {Source: "A", Destination: "Y", Asset: usd, Amount: big.NewInt(70)}, + {Source: "B", Destination: "Y", Asset: usd, Amount: big.NewInt(50)}, + }) +} + +func TestSend_FIFOOrder(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 10, {"B", "", usd, ""}: 10, {"C", "", usd, ""}: 10}) + pull(rs, "A", big.NewInt(10), big.NewInt(0), "") + pull(rs, "B", big.NewInt(10), big.NewInt(0), "") + pull(rs, "C", big.NewInt(10), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(30), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(10)}, + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(10)}, + {Source: "C", Destination: "X", Asset: usd, Amount: big.NewInt(10)}, + }) +} + +func TestSend_ExactMatchNoRequeue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 50}) + pull(rs, "A", big.NewInt(50), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(50), nil) // exact + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(50)}}) + // nothing left + _ = rs.SendUncapped(strptr("Y"), "", nil) + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(50)}}) +} + +func TestSend_CapExceedsAvailableDrains(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(500), nil) // more than available; drains 100, no leftover + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}}) +} + +func TestSend_ZeroCapIsNoOp(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(0), nil) + wantPostings(t, rs, []runtime.Posting{}) + // source remains -> uncapped drain still sees it + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}}) +} + +func TestSend_NegativeCapIsNoOp(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(-5), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +func TestSend_NoSourcesIsNoOp(t *testing.T) { + rs, _ := newRS(nil) + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- Send: posting merge -------------------------------------------------- + +func TestSend_MergesWithinSingleDrain(t *testing.T) { + // Two same-source funds drained by ONE Send to the same destination merge + // into a single posting (mirrors fundsQueue.compactTop within one Pull). + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(60), big.NewInt(0), "") // source A:60 + pull(rs, "A", big.NewInt(40), big.NewInt(0), "") // source A:40 + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) // drains both A:60 then A:40 -> one posting + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}}) +} + +func TestSend_DoesNotMergeAcrossSeparateSends(t *testing.T) { + // Two separate Send calls, same src->dst->asset, are NOT merged. This + // matches the interpreter (fundsQueue), which only merges adjacent funds + // within a single Pull, never across send statements. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(40), nil) // posting A->X 40, requeue A:60 + _ = rs.Send(strptr("X"), "", big.NewInt(40), nil) // separate send: NOT merged, requeue A:20 + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + }) +} + +func TestSend_DoesNotMergeDifferentDestination(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(40), nil) + _ = rs.Send(strptr("Y"), "", big.NewInt(40), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + {Source: "A", Destination: "Y", Asset: usd, Amount: big.NewInt(40)}, + }) +} + +// --- Send: destination balance credit (the cache-bug fix) ----------------- + +func TestSend_CreditsDestinationOverExistingStoreBalance(t *testing.T) { + // X already has 500 in the store. Crediting must fetch that first, not + // treat X as 0. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"X", "", usd, ""}: 500}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) + wantBalance(t, rs, "X", 600) +} + +// --- Send: refund path (dest == nil) ------------------------------------- + +func TestSend_RefundCreditsSourceNoPosting(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") // A -> 0, source A:100 + _ = rs.Send(nil, "", big.NewInt(60), nil) // refund 60 to A, requeue A:40 + wantBalance(t, rs, "A", 60) + wantPostings(t, rs, []runtime.Posting{}) + // remaining 40 still queued + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}}) +} + +// --- SendUncapped --------------------------------------------------------- + +func TestSendUncapped_DrainsAllToDestination(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 50}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}, + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(50)}, + }) +} + +func TestSendUncapped_RefundsAll(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 50}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") // A -> 0 + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") // B -> 0 + _ = rs.SendUncapped(nil, "", nil) // refund both + wantBalance(t, rs, "A", 100) + wantBalance(t, rs, "B", 50) + wantPostings(t, rs, []runtime.Posting{}) +} + +func TestSendUncapped_NoSourcesIsNoOp(t *testing.T) { + rs, _ := newRS(nil) + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- GetPostings returns a defensive copy -------------------------------- + +func TestGetPostings_ReturnsCopy(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) + + p := rs.GetPostings() + if len(p) != 1 { + t.Fatalf("expected 1 posting, got %d", len(p)) + } + p[0].Amount = big.NewInt(999999) // mutate the returned slice + + p2 := rs.GetPostings() + if p2[0].Amount.Cmp(big.NewInt(100)) != 0 { + t.Errorf("internal posting was mutated via returned slice: amount=%d", p2[0].Amount) + } +} + +// --- big.Int precision (beyond int64) ------------------------------------ + +func TestBigInt_AmountsBeyondInt64(t *testing.T) { + // 10^30 is far beyond int64's ~9.2*10^18 ceiling; the whole pipeline + // (store -> Pull -> Send -> posting + balances) must carry it losslessly. + huge, _ := new(big.Int).SetString("1000000000000000000000000000000", 10) // 1e30 + store := newMockStore(nil) + store.balances[runtime.PairKey{"A", "", usd, ""}] = new(big.Int).Set(huge) + rs := runtime.New(store) + rs.SetCurrentAsset(usd) + + got := pull(rs, "A", new(big.Int).Set(huge), big.NewInt(0), "") + if got.Cmp(huge) != 0 { + t.Fatalf("Pull returned %s, want %s", got, huge) + } + _ = rs.Send(strptr("X"), "", new(big.Int).Set(huge), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: new(big.Int).Set(huge)}, + }) + if bal := accBal(rs, "X", "", usd, ""); bal.Cmp(huge) != 0 { + t.Errorf("X balance = %s, want %s", bal, huge) + } + if bal := accBal(rs, "A", "", usd, ""); bal.Sign() != 0 { + t.Errorf("A balance = %s, want 0", bal) + } +} + +func TestBigInt_GetAccountBalanceReturnsCopy(t *testing.T) { + // Mutating the returned balance must not corrupt the cache. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + b := accBal(rs, "A", "", usd, "") + b.SetInt64(999999) + wantBalance(t, rs, "A", 100) +} + +// --- Prewarm (batched balance seeding) ----------------------------------- + +func TestPrewarm_SeedsCacheAndSkipsStore(t *testing.T) { + rs, store := newRS(nil) // store has nothing + rs.Prewarm(map[runtime.PairKey]*big.Int{ + {"A", "", usd, ""}: big.NewInt(100), + {"B", "", usd, "red"}: big.NewInt(40), + }) + wantBalance(t, rs, "A", 100) + if b := accBal(rs, "B", "", usd, "red"); b.Cmp(big.NewInt(40)) != 0 { + t.Errorf("B red = %s, want 40", b) + } + // nothing was fetched lazily — the batch seed covered it + if c := store.callCount("A", usd); c != 0 { + t.Errorf("store consulted %d times for A, want 0", c) + } +} + +func TestPrewarm_ClonesValues(t *testing.T) { + rs, _ := newRS(nil) + seed := big.NewInt(100) + rs.Prewarm(map[runtime.PairKey]*big.Int{{"A", "", usd, ""}: seed}) + seed.SetInt64(999) // mutate caller's value after seeding + wantBalance(t, rs, "A", 100) +} + +func TestPrewarm_DoesNotClobberLiveValue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(30), big.NewInt(0), "") // A -> 70 + rs.Prewarm(map[runtime.PairKey]*big.Int{{"A", "", usd, ""}: big.NewInt(100)}) // must NOT reset to 100 + wantBalance(t, rs, "A", 70) +} + +// --- ForcePosting (direct src->dst, bypassing the queue) ----------------- + +func TestForcePosting_DebitsSourceCreditsDestAndRecords(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 10}) + _ = rs.ForcePosting("A", "", "B", "", usd, "", big.NewInt(30)) + wantBalance(t, rs, "A", 70) + wantBalance(t, rs, "B", 40) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "B", Asset: usd, Amount: big.NewInt(30)}, + }) +} + +func TestForcePosting_UsesExplicitAssetNotCurrent(t *testing.T) { + // asset-scaling emits postings on a scaled asset, distinct from currentAsset. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", "USD/2", ""}: 500}) + rs.SetCurrentAsset(usd) // current asset is USD, but we post on USD/2 + _ = rs.ForcePosting("A", "", "B", "", "USD/2", "", big.NewInt(500)) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "B", Asset: "USD/2", Amount: big.NewInt(500)}, + }) + if b := accBal(rs, "A", "", "USD/2", ""); b.Sign() != 0 { + t.Errorf("A USD/2 = %s, want 0", b) + } +} + +func TestForcePosting_ZeroIsNoOp(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + _ = rs.ForcePosting("A", "", "B", "", usd, "", big.NewInt(0)) + wantBalance(t, rs, "A", 100) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- Save (numscript `save` statement) ----------------------------------- + +func TestSave_ReducesByAmount(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + _ = rs.Save("A", "", usd, "", big.NewInt(30)) + wantBalance(t, rs, "A", 70) +} + +func TestSave_FlooredAtZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 20}) + _ = rs.Save("A", "", usd, "", big.NewInt(50)) // would be -30, floored to 0 + wantBalance(t, rs, "A", 0) +} + +func TestSave_AllZeroesPositiveBalance(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 80}) + _ = rs.Save("A", "", usd, "", nil) // save all + wantBalance(t, rs, "A", 0) +} + +func TestSave_AllLeavesNegativeUntouched(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: -40}) + _ = rs.Save("A", "", usd, "", nil) + wantBalance(t, rs, "A", -40) +} + +func TestSave_ThenPullSeesProtectedBalance(t *testing.T) { + // after saving, a bounded Pull can only take what's left + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + _ = rs.Save("A", "", usd, "", big.NewInt(70)) // A -> 30 available + got := pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + wantReturn(t, "Pull", got, 30) + wantBalance(t, rs, "A", 0) +} + +// --- marks (cheap oneof backtracking) ------------------------------------ + +func TestMark_RewindUndoesPullsAndBalances(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 80}) + + rs.MarkPush() // at depth 0 + pull(rs, "A", big.NewInt(60), big.NewInt(0), "") + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") + // balances debited, two sources queued + wantBalance(t, rs, "A", 40) + wantBalance(t, rs, "B", 30) + + require.NoError(t, rs.MarkEnd(true)) + // balances repaid, queue emptied, and the region is closed by the same call + wantBalance(t, rs, "A", 100) + wantBalance(t, rs, "B", 80) + require.False(t, rs.HasOpenMark()) + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{}) // nothing left to send +} + +func TestMark_OneofFailedBranchThenRealBranch(t *testing.T) { + // Models `oneof` exactly as the interpreter and the compiled bytecode emit it: + // branch 1 falls short, so its region is closed with a rewind and a fresh one is + // opened for branch 2, which covers the amount and commits. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 30, {"B", "", usd, ""}: 100}) + + rs.MarkPush() + + // branch 1: @A can only provide 30 of the needed 100 -> abandon + got := pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + wantReturn(t, "branch1 pull", got, 30) // short + require.NoError(t, rs.MarkEnd(true)) + wantBalance(t, rs, "A", 30) // A untouched after backtrack + + // close-and-reopen: the fresh mark is identical to the one just rewound + rs.MarkPush() + + // branch 2: @B covers it + got = pull(rs, "B", big.NewInt(100), big.NewInt(0), "") + wantReturn(t, "branch2 pull", got, 100) + + require.NoError(t, rs.MarkEnd(false)) + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(100)}, + }) + wantBalance(t, rs, "A", 30) + wantBalance(t, rs, "B", 0) +} + +func TestMark_RewindKeepsSourcesQueuedBeforeThePush(t *testing.T) { + // A mark opened mid-stream must only undo what came after it. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100, {"B", "", usd, ""}: 100}) + pull(rs, "A", big.NewInt(40), big.NewInt(0), "") // kept + + rs.MarkPush() + pull(rs, "B", big.NewInt(70), big.NewInt(0), "") // undone + require.NoError(t, rs.MarkEnd(true)) + + wantBalance(t, rs, "A", 60) // still debited + wantBalance(t, rs, "B", 100) // repaid + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + }) +} + +func TestMark_CommitKeepsWhatTheRegionPulled(t *testing.T) { + // The success path: a branch covered the amount, so the region is popped + // without a rewind and its funds stay queued. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + + rs.MarkPush() + pull(rs, "A", big.NewInt(70), big.NewInt(0), "") + require.NoError(t, rs.MarkEnd(false)) + + require.False(t, rs.HasOpenMark()) + wantBalance(t, rs, "A", 30) + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(70)}, + }) +} + +func TestMark_NestedRegionsRewindIndependently(t *testing.T) { + // The inner rewind must undo only the inner region; the outer one's funds + // survive it and are only undone by the outer rewind. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", "", usd, ""}: 100, {"B", "", usd, ""}: 100, {"C", "", usd, ""}: 100, + }) + + rs.MarkPush() + pull(rs, "A", big.NewInt(10), big.NewInt(0), "") + + rs.MarkPush() + pull(rs, "B", big.NewInt(20), big.NewInt(0), "") + require.NoError(t, rs.MarkEnd(true)) + + wantBalance(t, rs, "A", 90) // outer pull survives the inner rewind + wantBalance(t, rs, "B", 100) // inner pull undone + require.True(t, rs.HasOpenMark(), "outer mark must still be open") + + pull(rs, "C", big.NewInt(30), big.NewInt(0), "") + require.NoError(t, rs.MarkEnd(true)) // outer rewind: undoes A and C, and closes + + require.False(t, rs.HasOpenMark()) + wantBalance(t, rs, "A", 100) + wantBalance(t, rs, "C", 100) + _ = rs.SendUncapped(strptr("X"), "", nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// A caller cannot name a queue depth, so the only way to misuse a mark is to +// end one that was never pushed. Both flags must report it rather than +// panicking on an out-of-range truncation, which is what the old index-valued +// Restore did. +func TestMark_EndWithNoOpenMark(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + + require.ErrorIs(t, rs.MarkEnd(true), runtime.ErrNoOpenMark) + require.ErrorIs(t, rs.MarkEnd(false), runtime.ErrNoOpenMark) + + // and after a balanced region has closed + rs.MarkPush() + require.NoError(t, rs.MarkEnd(false)) + require.ErrorIs(t, rs.MarkEnd(true), runtime.ErrNoOpenMark) + require.ErrorIs(t, rs.MarkEnd(false), runtime.ErrNoOpenMark) +} + +// HasOpenMark is what lets a caller enforce the precondition Send and +// SetCurrentAsset document: the VM checks it at the two opcodes instead of +// discovering the damage afterwards. +func TestMark_HasOpenMarkReportsAnOpenRegion(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + require.False(t, rs.HasOpenMark()) + + rs.MarkPush() + require.True(t, rs.HasOpenMark()) + rs.MarkPush() + require.True(t, rs.HasOpenMark()) + + require.NoError(t, rs.MarkEnd(false)) + require.True(t, rs.HasOpenMark(), "the outer region is still open") + require.NoError(t, rs.MarkEnd(false)) + require.False(t, rs.HasOpenMark()) +} + +// ForcePosting inside a region must be undone by a rewind: it is the one +// posting-emitting operation that is legal inside one (it consumes no queue entry), +// and it is what a scaled source does inside a `oneof` branch. +func TestMark_RewindReversesForcePostings(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + + rs.MarkPush() + require.NoError(t, rs.ForcePosting("A", "", "swap", "", usd, "", big.NewInt(30))) + wantBalance(t, rs, "A", 70) + wantBalance(t, rs, "swap", 30) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "swap", Asset: usd, Amount: big.NewInt(30)}, + }) + + require.NoError(t, rs.MarkEnd(true)) + + // posting dropped, and both sides of it credited back + wantPostings(t, rs, []runtime.Posting{}) + wantBalance(t, rs, "A", 100) + wantBalance(t, rs, "swap", 0) + require.False(t, rs.HasOpenMark()) +} + +// The realistic scaling shape: a swap out and back in a different asset, plus a +// pull, all inside one region. A rewind must leave every asset untouched. +func TestMark_RewindReversesAScaledSwapAndItsPull(t *testing.T) { + const eur3 = "EUR/3" + rs, _ := newRS(map[runtime.PairKey]int64{ + {"acc", "", usd, ""}: 1, + {"acc", "", eur3, ""}: 10, + }) + + rs.MarkPush() + // acc converts 10 EUR/3 through @swap into 1 more of the current asset + require.NoError(t, rs.ForcePosting("acc", "", "swap", "", eur3, "", big.NewInt(10))) + require.NoError(t, rs.ForcePosting("swap", "", "acc", "", usd, "", big.NewInt(1))) + // then pulls what it now holds + pull(rs, "acc", big.NewInt(2), big.NewInt(0), "") + require.Len(t, rs.GetPostings(), 2) + + require.NoError(t, rs.MarkEnd(true)) + + wantPostings(t, rs, []runtime.Posting{}) + wantBalance(t, rs, "acc", 1) // usd back to its starting balance + if got := accBal(rs, "acc", "", eur3, ""); got.Cmp(big.NewInt(10)) != 0 { + t.Errorf("balance(acc, %s) = %s, want 10", eur3, got) + } + if got := accBal(rs, "swap", "", eur3, ""); got.Sign() != 0 { + t.Errorf("balance(swap, %s) = %s, want 0", eur3, got) + } + wantBalance(t, rs, "swap", 0) +} + +// A rewind must reverse only the region's own postings; ones emitted before the +// push survive it. +func TestMark_RewindKeepsPostingsEmittedBeforeThePush(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + + require.NoError(t, rs.ForcePosting("A", "", "keep", "", usd, "", big.NewInt(10))) + rs.MarkPush() + require.NoError(t, rs.ForcePosting("A", "", "drop", "", usd, "", big.NewInt(20))) + require.NoError(t, rs.MarkEnd(true)) + + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "keep", Asset: usd, Amount: big.NewInt(10)}, + }) + wantBalance(t, rs, "A", 90) + wantBalance(t, rs, "keep", 10) + wantBalance(t, rs, "drop", 0) +} + +// Nested: the inner rewind drops only the inner posting; the outer rewind then drops +// the outer one too. +func TestMark_NestedRegionsReversePostingsIndependently(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + + rs.MarkPush() + require.NoError(t, rs.ForcePosting("A", "", "outer", "", usd, "", big.NewInt(10))) + + rs.MarkPush() + require.NoError(t, rs.ForcePosting("A", "", "inner", "", usd, "", big.NewInt(20))) + require.NoError(t, rs.MarkEnd(true)) + + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "outer", Asset: usd, Amount: big.NewInt(10)}, + }) + wantBalance(t, rs, "inner", 0) + wantBalance(t, rs, "A", 90) // outer posting survives the inner rewind + + require.NoError(t, rs.MarkEnd(true)) + + wantPostings(t, rs, []runtime.Posting{}) + wantBalance(t, rs, "outer", 0) + wantBalance(t, rs, "A", 100) +} + +// Committing keeps them: a region that succeeded emits its postings for real. +func TestMark_CommitKeepsForcePostings(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + + rs.MarkPush() + require.NoError(t, rs.ForcePosting("A", "", "swap", "", usd, "", big.NewInt(30))) + require.NoError(t, rs.MarkEnd(false)) + + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "swap", Asset: usd, Amount: big.NewInt(30)}, + }) + wantBalance(t, rs, "A", 70) + wantBalance(t, rs, "swap", 30) +} + +// A rewind reverses a posting using the asset recorded *on the posting*, not +// currentAsset — so a posting in another asset is still undone correctly. +func TestMark_RewindUsesThePostingsOwnAsset(t *testing.T) { + const other = "EUR/2" + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", other, ""}: 50}) + + rs.MarkPush() // currentAsset is usd, the posting is in EUR/2 + require.NoError(t, rs.ForcePosting("A", "", "B", "", other, "", big.NewInt(20))) + require.NoError(t, rs.MarkEnd(true)) + + if got := accBal(rs, "A", "", other, ""); got.Cmp(big.NewInt(50)) != 0 { + t.Errorf("balance(A, %s) = %s, want 50", other, got) + } + if got := accBal(rs, "B", "", other, ""); got.Sign() != 0 { + t.Errorf("balance(B, %s) = %s, want 0", other, got) + } + // currentAsset untouched by the reversal + wantBalance(t, rs, "A", 0) +} + +func TestMark_ResetDropsAnOpenMark(t *testing.T) { + // A run that fails mid-region must not leak its open marks into the + // next run on a reused RunState. + rs, store := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 100}) + rs.MarkPush() + pull(rs, "A", big.NewInt(40), big.NewInt(0), "") + require.True(t, rs.HasOpenMark()) + + rs.Reset(store) + require.False(t, rs.HasOpenMark()) + require.ErrorIs(t, rs.MarkEnd(false), runtime.ErrNoOpenMark) +} + +// --- color ---------------------------------------------------------------- + +func TestColor_BalancesTrackedSeparatelyPerColor(t *testing.T) { + // Same account+asset, two colors: each (account, asset, color) is its own + // balance slot, fetched from the store independently. + rs, store := newRS(map[runtime.PairKey]int64{ + {"A", "", usd, "red"}: 100, + {"A", "", usd, "blue"}: 40, + }) + if got := accBal(rs, "A", "", usd, "red"); got.Cmp(big.NewInt(100)) != 0 { + t.Errorf("red balance = %d, want 100", got) + } + if got := accBal(rs, "A", "", usd, "blue"); got.Cmp(big.NewInt(40)) != 0 { + t.Errorf("blue balance = %d, want 40", got) + } + // uncolored slot is independent and absent -> 0 + if got := accBal(rs, "A", "", usd, ""); got.Cmp(big.NewInt(0)) != 0 { + t.Errorf("uncolored balance = %d, want 0", got) + } + if c := store.calls[runtime.PairKey{"A", "", usd, "red"}]; c != 1 { + t.Errorf("red fetched %d times, want 1", c) + } +} + +func TestColor_PullTagsSourceAndPostingCarriesColor(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, "red"}: 100}) + pull(rs, "A", big.NewInt(60), big.NewInt(0), "red") + _ = rs.Send(strptr("X"), "", big.NewInt(60), strptr("red")) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(60)}, + }) + // destination credited on the colored slot, source debited on it + if got := accBal(rs, "X", "", usd, "red"); got.Cmp(big.NewInt(60)) != 0 { + t.Errorf("X red = %d, want 60", got) + } + if got := accBal(rs, "A", "", usd, "red"); got.Cmp(big.NewInt(40)) != 0 { + t.Errorf("A red = %d, want 40", got) + } +} + +func TestColor_SendSkipsNonMatchingColorLeavingItQueued(t *testing.T) { + // Queue order: red, blue, red. A red Send must drain the two red sources + // (skipping blue, leaving it queued), exactly like fundsQueue.Pull's + // color-skip. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", "", usd, "red"}: 50, + {"B", "", usd, "blue"}: 30, + {"C", "", usd, "red"}: 40, + }) + pull(rs, "A", big.NewInt(50), big.NewInt(0), "red") + pull(rs, "B", big.NewInt(30), big.NewInt(0), "blue") + pull(rs, "C", big.NewInt(40), big.NewInt(0), "red") + + _ = rs.Send(strptr("X"), "", big.NewInt(100), strptr("red")) // only 90 red available; blue stays put + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(50)}, + {Source: "C", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(40)}, + }) + + // the skipped blue source is still queued and drains on a blue send + _ = rs.Send(strptr("Y"), "", big.NewInt(100), strptr("blue")) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(50)}, + {Source: "C", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(40)}, + {Source: "B", Destination: "Y", Asset: usd, Color: "blue", Amount: big.NewInt(30)}, + }) +} + +func TestColor_SendDoesNotMergeAcrossColors(t *testing.T) { + // Same src->dst->asset but different colors are distinct postings even + // within consecutive drains. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", "", usd, "red"}: 40, + {"A", "", usd, "blue"}: 40, + }) + pull(rs, "A", big.NewInt(40), big.NewInt(0), "red") + pull(rs, "A", big.NewInt(40), big.NewInt(0), "blue") + _ = rs.SendUncapped(strptr("X"), "", strptr("red")) + _ = rs.SendUncapped(strptr("X"), "", strptr("blue")) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(40)}, + {Source: "A", Destination: "X", Asset: usd, Color: "blue", Amount: big.NewInt(40)}, + }) +} + +func TestColor_MatchAnyDrainsMixedColorsPreservingEach(t *testing.T) { + // This is the mode the interpreter's destinations use (fundsQueue.PullAnything): + // one drain (color == nil) consumes funds of several colors at once, and each + // posting keeps its source fund's own color. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", "", usd, "red"}: 50, + {"B", "", usd, "blue"}: 30, + {"C", "", usd, ""}: 20, + }) + pull(rs, "A", big.NewInt(50), big.NewInt(0), "red") + pull(rs, "B", big.NewInt(30), big.NewInt(0), "blue") + pull(rs, "C", big.NewInt(20), big.NewInt(0), "") + + _ = rs.Send(strptr("X"), "", big.NewInt(100), nil) // nil = match anything + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(50)}, + {Source: "B", Destination: "X", Asset: usd, Color: "blue", Amount: big.NewInt(30)}, + {Source: "C", Destination: "X", Asset: usd, Color: "", Amount: big.NewInt(20)}, + }) + // destination credited on each respective color slot + if b := accBal(rs, "X", "", usd, "red"); b.Cmp(big.NewInt(50)) != 0 { + t.Errorf("X red = %s, want 50", b) + } + if b := accBal(rs, "X", "", usd, "blue"); b.Cmp(big.NewInt(30)) != 0 { + t.Errorf("X blue = %s, want 30", b) + } +} + +func TestColor_RefundUsesSourceColor(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, "red"}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "red") // A red -> 0 + _ = rs.Send(nil, "", big.NewInt(60), strptr("red")) // refund 60 to A's red slot + if got := accBal(rs, "A", "", usd, "red"); got.Cmp(big.NewInt(60)) != 0 { + t.Errorf("A red after refund = %d, want 60", got) + } + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- end-to-end flow ------------------------------------------------------ + +func TestEndToEnd_TwoSourcesSplitAcrossDestinations(t *testing.T) { + rs, store := newRS(map[runtime.PairKey]int64{ + {"alice", "", usd, ""}: 100, + {"bob", "", usd, ""}: 100, + {"carol", "", usd, ""}: 0, + {"dave", "", usd, ""}: 0, + }) + pull(rs, "alice", big.NewInt(100), big.NewInt(0), "") + pull(rs, "bob", big.NewInt(100), big.NewInt(0), "") + + _ = rs.Send(strptr("carol"), "", big.NewInt(150), nil) // alice:100 fully, bob:50 partial (requeue bob:50) + _ = rs.Send(strptr("dave"), "", big.NewInt(50), nil) // bob:50 fully + + wantPostings(t, rs, []runtime.Posting{ + {Source: "alice", Destination: "carol", Asset: usd, Amount: big.NewInt(100)}, + {Source: "bob", Destination: "carol", Asset: usd, Amount: big.NewInt(50)}, + {Source: "bob", Destination: "dave", Asset: usd, Amount: big.NewInt(50)}, + }) + wantBalance(t, rs, "alice", 0) + wantBalance(t, rs, "bob", 0) + wantBalance(t, rs, "carol", 150) + wantBalance(t, rs, "dave", 50) + + // each account fetched from store exactly once + for _, acct := range []string{"alice", "bob", "carol", "dave"} { + if c := store.callCount(acct, usd); c != 1 { + t.Errorf("%s fetched %d times, want 1", acct, c) + } + } +} diff --git a/internal/interpreter/asset_scaling.go b/internal/runtime/scaling.go similarity index 56% rename from internal/interpreter/asset_scaling.go rename to internal/runtime/scaling.go index 2fd74fcf..3c5f0b42 100644 --- a/internal/interpreter/asset_scaling.go +++ b/internal/runtime/scaling.go @@ -1,39 +1,64 @@ -package interpreter +package runtime import ( "fmt" "math/big" "slices" + "strconv" "strings" "github.com/formancehq/numscript/internal/utils" ) -func assetToScaledAsset(asset Asset) Asset { - strAsset := string(asset) - parts := strings.Split(strAsset, "/") +// Asset-scaling arithmetic: converting between different scales of the same base +// asset (e.g. EUR, EUR/2, EUR/4) with no rounding error and no spare amount. +// Value-free — operates on asset strings, scales, and amounts — so both the +// interpreter and the VM can drive it. + +// GetBaseAndScale splits an asset into its base and scale, e.g. "EUR/2" -> +// ("EUR", 2) and "EUR" -> ("EUR", 0). +func GetBaseAndScale(asset string) (string, int64) { + parts := strings.Split(asset, "/") + if len(parts) == 2 { + scale, err := strconv.ParseInt(parts[1], 10, 64) + if err == nil { + return parts[0], scale + } + // fallback if parsing fails + return parts[0], 0 + } + return asset, 0 +} + +// AssetToScaledAsset maps an asset to its wildcard-scale form, e.g. "EUR/2" -> +// "EUR/*" and "EUR" -> "EUR/*". +func AssetToScaledAsset(asset string) string { + parts := strings.Split(asset, "/") if len(parts) == 1 { - return Asset(strAsset + "/*") + return asset + "/*" } - return Asset(parts[0] + "/*") + return parts[0] + "/*" } -func buildScaledAsset(baseAsset string, scale int64) string { +// BuildScaledAsset composes a base asset and a scale into an asset string, e.g. +// ("EUR", 2) -> "EUR/2" and ("EUR", 0) -> "EUR". +func BuildScaledAsset(baseAsset string, scale int64) string { if scale == 0 { return baseAsset } return fmt.Sprintf("%s/%d", baseAsset, scale) } -func getAssets(accountBalances []AccountBalance, baseAsset string) map[int64]*big.Int { +// GetAssets collects, per scale, the (uncolored) amount an account holds of +// baseAsset. Scaling converts only uncolored balances and emits uncolored +// postings, so colored balances are excluded. +func GetAssets(accountBalances []AccountBalance, baseAsset string) map[int64]*big.Int { result := make(map[int64]*big.Int) for _, accBalance := range accountBalances { if accBalance.Color != "" { - // scaling converts only uncolored balances, and emits uncolored - // postings, so a colored balance must not be treated as available continue } - accBalanceAsset, scale := Asset(accBalance.Asset).GetBaseAndScale() + accBalanceAsset, scale := GetBaseAndScale(accBalance.Asset) if accBalanceAsset == baseAsset { result[scale] = new(big.Int).Set(accBalance.Amount) } @@ -41,23 +66,25 @@ func getAssets(accountBalances []AccountBalance, baseAsset string) map[int64]*bi return result } -type scalePair struct { - scale int64 - amount *big.Int +// ScalePair is an (amount at scale) entry: a conversion output of +// FindScalingSolution. +type ScalePair struct { + Scale int64 + Amount *big.Int } -func getSortedAssets(scales map[int64]*big.Int) []scalePair { - var assets []scalePair +func getSortedAssets(scales map[int64]*big.Int) []ScalePair { + var assets []ScalePair for k, v := range scales { - assets = append(assets, scalePair{ - scale: k, - amount: v, + assets = append(assets, ScalePair{ + Scale: k, + Amount: v, }) } // Sort in DESC order (e.g. EUR/4, .., EUR/1, EUR) - slices.SortFunc(assets, func(p scalePair, other scalePair) int { - return int(other.scale - p.scale) + slices.SortFunc(assets, func(p ScalePair, other ScalePair) int { + return int(other.Scale - p.Scale) }) return assets @@ -102,24 +129,24 @@ func applyScalingInv(amt *big.Int, scalingFactor *big.Rat) *big.Int { return availableCurrencyScaled } -// Find a set of conversions from the available "scales", to -// [ASSET/$neededAmtScale $neededAmt], so that there's no rounding error -// and no spare amount -func findScalingSolution( +// FindScalingSolution finds a set of conversions from the available "scales" to +// [ASSET/$neededAmtScale $neededAmt], so that there's no rounding error and no +// spare amount. neededAmt may be nil (meaning "convert everything available"). +func FindScalingSolution( neededAmt *big.Int, // <- can be nil neededAmtScale int64, scales map[int64]*big.Int, -) ([]scalePair, *big.Int) { +) ([]ScalePair, *big.Int) { if ownedAmt, ok := scales[neededAmtScale]; ok && neededAmt != nil { // Note we don't mutate the input value neededAmt = new(big.Int).Sub(neededAmt, ownedAmt) } - var out []scalePair + var out []ScalePair totalSent := big.NewInt(0) for _, p := range getSortedAssets(scales) { - if neededAmtScale == p.scale { + if neededAmtScale == p.Scale { // We don't convert assets we already have continue } @@ -128,11 +155,11 @@ func findScalingSolution( break } - scalingFactor := getScalingFactor(neededAmtScale, p.scale) + scalingFactor := getScalingFactor(neededAmtScale, p.Scale) // scale the original amount to the current currency // availableCurrencyScaled := floor(p.amount * scalingFactor) - availableCurrencyScaled := applyScaling(p.amount, scalingFactor) + availableCurrencyScaled := applyScaling(p.Amount, scalingFactor) var taken *big.Int // := min(availableCurrencyScaled, (neededAmt-totalSent) ?? ∞) if neededAmt == nil { @@ -153,9 +180,9 @@ func findScalingSolution( totalSent.Add(totalSent, actuallyTaken) - out = append(out, scalePair{ - scale: p.scale, - amount: intPart, + out = append(out, ScalePair{ + Scale: p.Scale, + Amount: intPart, }) } diff --git a/internal/interpreter/asset_scaling_test.go b/internal/runtime/scaling_test.go similarity index 74% rename from internal/interpreter/asset_scaling_test.go rename to internal/runtime/scaling_test.go index 85f79365..9f49bb39 100644 --- a/internal/interpreter/asset_scaling_test.go +++ b/internal/runtime/scaling_test.go @@ -1,16 +1,17 @@ -package interpreter +package runtime_test import ( "math/big" "testing" + "github.com/formancehq/numscript/internal/runtime" "github.com/stretchr/testify/require" ) func TestGetAssetsExcludesColoredBalances(t *testing.T) { // Scaling converts only uncolored balances and emits uncolored postings, so a // colored balance for the same base asset must not be offered as a candidate. - assets := getAssets([]AccountBalance{ + assets := runtime.GetAssets([]runtime.AccountBalance{ {Asset: "USD", Color: "", Amount: big.NewInt(2)}, {Asset: "USD/4", Color: "RED", Amount: big.NewInt(999)}, // colored: excluded {Asset: "USD/2", Color: "", Amount: big.NewInt(50)}, @@ -27,7 +28,7 @@ func TestScalingAvoidSwappingAlreadyHaveAsset(t *testing.T) { // Need [USD/2 200] // Got: {USD/2 100, USD 2} // we only want [USD 1] to be swapped - sol, got := findScalingSolution( + sol, got := runtime.FindScalingSolution( big.NewInt(200), 2, map[int64]*big.Int{ @@ -35,7 +36,7 @@ func TestScalingAvoidSwappingAlreadyHaveAsset(t *testing.T) { 0: big.NewInt(2), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(1)}, }, sol) require.Equal(t, big.NewInt(100), got) @@ -44,14 +45,14 @@ func TestScalingAvoidSwappingAlreadyHaveAsset(t *testing.T) { func TestScalingAvoidSpareAmt(t *testing.T) { // Need [USD/2 1] // Got: {USD 99} - sol, got := findScalingSolution( + sol, got := runtime.FindScalingSolution( big.NewInt(1), 2, map[int64]*big.Int{ 0: big.NewInt(99), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(1)}, }, sol) require.Equal(t, big.NewInt(100), got) @@ -60,35 +61,35 @@ func TestScalingAvoidSpareAmt(t *testing.T) { func TestScalingAvoidSpareAmt2(t *testing.T) { // Need [USD/2 1] // Got: {USD 99} - sol, got := findScalingSolution( + sol, got := runtime.FindScalingSolution( big.NewInt(399), 2, map[int64]*big.Int{ 0: big.NewInt(9999999), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(4)}, }, sol) require.Equal(t, big.NewInt(400), got) } func TestScalingDownAvoidSpareAmt(t *testing.T) { - sol, got := findScalingSolution( + sol, got := runtime.FindScalingSolution( big.NewInt(1), 0, map[int64]*big.Int{ 2: big.NewInt(123), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {2, big.NewInt(100)}, }, sol) require.Equal(t, big.NewInt(1), got) } func TestScalingZeroNeeded(t *testing.T) { - sol, tot := findScalingSolution( + sol, tot := runtime.FindScalingSolution( big.NewInt(0), 42, map[int64]*big.Int{ @@ -96,12 +97,12 @@ func TestScalingZeroNeeded(t *testing.T) { 1: big.NewInt(1), }) - require.Equal(t, []scalePair(nil), sol) + require.Equal(t, []runtime.ScalePair(nil), sol) require.Equal(t, big.NewInt(0), tot) } func TestDoNotAllowSpare(t *testing.T) { - sol, tot := findScalingSolution( + sol, tot := runtime.FindScalingSolution( // Need [EUR/2 1] big.NewInt(1), 2, @@ -111,14 +112,14 @@ func TestDoNotAllowSpare(t *testing.T) { 0: big.NewInt(99), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(1)}, }, sol) require.Equal(t, big.NewInt(100), tot) } func TestRepro(t *testing.T) { - sol, tot := findScalingSolution( + sol, tot := runtime.FindScalingSolution( // Need [EUR/2 400] big.NewInt(400), 2, @@ -129,14 +130,14 @@ func TestRepro(t *testing.T) { 0: big.NewInt(99), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(4)}, }, sol) require.Equal(t, big.NewInt(400), tot) } func TestScalingSameAsset(t *testing.T) { - sol, tot := findScalingSolution( + sol, tot := runtime.FindScalingSolution( // Need [EUR/2 200] big.NewInt(200), 2, @@ -146,12 +147,12 @@ func TestScalingSameAsset(t *testing.T) { 2: big.NewInt(201), }) - require.Equal(t, []scalePair(nil), sol) + require.Equal(t, []runtime.ScalePair(nil), sol) require.Equal(t, big.NewInt(0), tot) } func TestScalingSolutionLowerScale(t *testing.T) { - sol, _ := findScalingSolution( + sol, _ := runtime.FindScalingSolution( // Need [COIN 1] big.NewInt(1), 0, @@ -160,13 +161,13 @@ func TestScalingSolutionLowerScale(t *testing.T) { 2: big.NewInt(900), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {2, big.NewInt(100)}, }, sol) } func TestScalingSolutionHigherScale(t *testing.T) { - sol, _ := findScalingSolution( + sol, _ := runtime.FindScalingSolution( // Need [EUR/2 200] big.NewInt(200), 2, @@ -176,14 +177,14 @@ func TestScalingSolutionHigherScale(t *testing.T) { 0: big.NewInt(4), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(2)}, }, sol) } func TestScalingSolutionHigherScaleNoSolution(t *testing.T) { // TODO change name - sol, _ := findScalingSolution( + sol, _ := runtime.FindScalingSolution( // Needed: [COIN/2 1] big.NewInt(1), 2, @@ -194,13 +195,13 @@ func TestScalingSolutionHigherScaleNoSolution(t *testing.T) { 1: big.NewInt(100), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {1, big.NewInt(1)}, }, sol) } func TestNoSolution(t *testing.T) { - sol, got := findScalingSolution( + sol, got := runtime.FindScalingSolution( // Need [USD/2 400] big.NewInt(400), 2, @@ -211,13 +212,13 @@ func TestNoSolution(t *testing.T) { }) require.Equal(t, big.NewInt(100), got) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(1)}, }, sol) } func TestNoSolution2(t *testing.T) { - sol, tot := findScalingSolution( + sol, tot := runtime.FindScalingSolution( // Need [USD/2 400] big.NewInt(400), 2, @@ -227,7 +228,7 @@ func TestNoSolution2(t *testing.T) { 3: big.NewInt(10), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {3, big.NewInt(10)}, {0, big.NewInt(1)}, }, sol) @@ -235,7 +236,7 @@ func TestNoSolution2(t *testing.T) { } func TestUnboundedScalingSameAsset(t *testing.T) { - sol, tot := findScalingSolution( + sol, tot := runtime.FindScalingSolution( // Need [USD/2 *] nil, 2, @@ -244,25 +245,25 @@ func TestUnboundedScalingSameAsset(t *testing.T) { 2: big.NewInt(123), }) - require.Equal(t, []scalePair(nil), sol) + require.Equal(t, []runtime.ScalePair(nil), sol) require.Equal(t, big.NewInt(0), tot) } func TestUnboundedScalingLowerAsset(t *testing.T) { - sol, _ := findScalingSolution( + sol, _ := runtime.FindScalingSolution( nil, 2, map[int64]*big.Int{ 0: big.NewInt(1), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {0, big.NewInt(1)}, }, sol) } func TestUnboundedScalinHigherAsset(t *testing.T) { - sol, _ := findScalingSolution( + sol, _ := runtime.FindScalingSolution( nil, 2, map[int64]*big.Int{ @@ -270,21 +271,21 @@ func TestUnboundedScalinHigherAsset(t *testing.T) { }) require.Equal(t, - []scalePair{ + []runtime.ScalePair{ {3, big.NewInt(10)}, }, sol) } func TestUnboundedScalinHigherAssetTrimRemainder(t *testing.T) { - sol, _ := findScalingSolution( + sol, _ := runtime.FindScalingSolution( nil, 2, map[int64]*big.Int{ 3: big.NewInt(15), }) - require.Equal(t, []scalePair{ + require.Equal(t, []runtime.ScalePair{ {3, big.NewInt(10)}, }, sol) } diff --git a/internal/runtime/values.go b/internal/runtime/values.go new file mode 100644 index 00000000..c8422d36 --- /dev/null +++ b/internal/runtime/values.go @@ -0,0 +1,94 @@ +package runtime + +import ( + "errors" + "fmt" + "math/big" + "regexp" + "strings" +) + +// Scalar value parsing / validation: the single source of truth for the textual +// form of numscript scalar values, shared by both runtimes (the tree-walking +// interpreter and the VM) and the compiler's vars encoder. Returns primitives +// (string / *big.Int / *big.Rat) and plain errors, so each caller adapts them +// into its own value/error types. + +const accountSegmentRegex = "[a-zA-Z0-9_-]+" + +// https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go +var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") + +// https://github.com/formancehq/ledger/blob/main/pkg/assets/asset.go +var assetNameRegex = regexp.MustCompile(`^[A-Z][A-Z0-9]{0,16}(_[A-Z]{1,16})?(\/\d{1,6})?$`) + +var percentRegex = regexp.MustCompile(`^([0-9]+)(?:[.]([0-9]+))?[%]$`) +var fractionRegex = regexp.MustCompile(`^([0-9]+)\s?[/]\s?([0-9]+)$`) + +var colorNameRegex = regexp.MustCompile("^[A-Z]*$") +var scopeNameRegex = regexp.MustCompile(`^[a-z0-9_]*$`) + +func ValidateAccount(addr string) bool { return accountNameRegex.MatchString(addr) } +func ValidateAsset(v string) bool { return assetNameRegex.MatchString(v) } +func ValidateColor(v string) bool { return colorNameRegex.MatchString(v) } +func ValidateScope(v string) bool { return scopeNameRegex.MatchString(v) } + +// ParseNumber parses a base-10 integer (arbitrary precision). +func ParseNumber(s string) (*big.Int, bool) { + return new(big.Int).SetString(s, 10) +} + +// ParsePortion parses a portion given as a percentage ("12%") or a fraction +// ("1/3"), returning it as a reduced ratio in [0, 1]. The error message is the +// reason (callers may wrap it into their own error type). +func ParsePortion(input string) (*big.Rat, error) { + var res *big.Rat + var ok bool + + percentMatch := percentRegex.FindStringSubmatch(input) + if len(percentMatch) != 0 { + integral := percentMatch[1] + fractional := percentMatch[2] + res, ok = new(big.Rat).SetString(integral + "." + fractional) + if !ok { + return nil, errors.New("invalid percent format") + } + res.Mul(res, big.NewRat(1, 100)) + } else { + fractionMatch := fractionRegex.FindStringSubmatch(input) + if len(fractionMatch) != 0 { + numerator := fractionMatch[1] + denominator := fractionMatch[2] + res, ok = new(big.Rat).SetString(numerator + "/" + denominator) + if !ok { + return nil, errors.New("invalid fractional format") + } + } + } + if res == nil { + return nil, errors.New("invalid format") + } + + if res.Cmp(big.NewRat(0, 1)) == -1 || res.Cmp(big.NewRat(1, 1)) == 1 { + return nil, errors.New("portion must be between 0% and 100% inclusive") + } + + return res, nil +} + +// ParseMonetary parses "ASSET AMOUNT" (e.g. "USD/2 100") into its asset and +// amount. +func ParseMonetary(source string) (asset string, amount *big.Int, err error) { + parts := strings.Split(source, " ") + if len(parts) != 2 { + return "", nil, fmt.Errorf("invalid monetary: %q", source) + } + if !ValidateAsset(parts[0]) { + return "", nil, fmt.Errorf("invalid asset: %q", parts[0]) + } + n, ok := ParseNumber(parts[1]) + if !ok { + return "", nil, fmt.Errorf("invalid monetary amount: %q", parts[1]) + } + return parts[0], n, nil +} diff --git a/internal/runtime/values_test.go b/internal/runtime/values_test.go new file mode 100644 index 00000000..56bdada3 --- /dev/null +++ b/internal/runtime/values_test.go @@ -0,0 +1,139 @@ +package runtime + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + testCases := []struct { + name string + fn func(string) bool + valid []string + bad []string + }{ + { + name: "account", + fn: ValidateAccount, + valid: []string{"world", "users:001", "a-b_c", "a:b:c"}, + bad: []string{"", "users:", ":users", "users::001", "a b", "@world"}, + }, + { + name: "asset", + fn: ValidateAsset, + valid: []string{"COIN", "USD/2", "EUR", "A", "TOKEN_X", "USD/123456"}, + bad: []string{"", "usd", "1USD", "USD/", "USD/1234567", "USD 2"}, + }, + { + name: "color", + fn: ValidateColor, + valid: []string{"", "RED", "ABC"}, + bad: []string{"red", "Red", "RED1", "RED_X", " RED"}, + }, + { + name: "scope", + fn: ValidateScope, + valid: []string{"", "s", "a_b", "s1"}, + bad: []string{"S", "a-b", "a:b"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, v := range tc.valid { + require.True(t, tc.fn(v), "expected %q to be valid", v) + } + for _, v := range tc.bad { + require.False(t, tc.fn(v), "expected %q to be invalid", v) + } + }) + } +} + +func TestParseNumber(t *testing.T) { + n, ok := ParseNumber("42") + require.True(t, ok) + require.Zero(t, n.Cmp(big.NewInt(42))) + + n, ok = ParseNumber("-7") + require.True(t, ok) + require.Zero(t, n.Cmp(big.NewInt(-7))) + + _, ok = ParseNumber("4.2") + require.False(t, ok) + _, ok = ParseNumber("") + require.False(t, ok) + _, ok = ParseNumber("0x10") + require.False(t, ok) +} + +func TestParsePortion(t *testing.T) { + testCases := []struct { + input string + want *big.Rat + }{ + {"1/2", big.NewRat(1, 2)}, + {"1 / 2", big.NewRat(1, 2)}, + {"0/2", big.NewRat(0, 1)}, + {"2/2", big.NewRat(1, 1)}, + {"50%", big.NewRat(1, 2)}, + {"12.5%", big.NewRat(1, 8)}, + {"0%", big.NewRat(0, 1)}, + {"100%", big.NewRat(1, 1)}, + } + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got, err := ParsePortion(tc.input) + require.NoError(t, err) + require.Zero(t, got.Cmp(tc.want), "got %s", got) + }) + } + + errCases := []struct { + input string + msg string + }{ + {"", "invalid format"}, + {"half", "invalid format"}, + {"1/2/3", "invalid format"}, + {"200%", "between 0% and 100%"}, + {"3/2", "between 0% and 100%"}, + {"-1/2", "invalid format"}, + {"1/0", "invalid fractional format"}, + } + for _, tc := range errCases { + t.Run("error: "+tc.input, func(t *testing.T) { + _, err := ParsePortion(tc.input) + require.ErrorContains(t, err, tc.msg) + }) + } +} + +func TestParseMonetary(t *testing.T) { + asset, amount, err := ParseMonetary("USD/2 100") + require.NoError(t, err) + require.Equal(t, "USD/2", asset) + require.Zero(t, amount.Cmp(big.NewInt(100))) + + _, _, err = ParseMonetary("USD/2 -1") + require.NoError(t, err) + + errCases := []struct { + input string + msg string + }{ + {"", "invalid monetary"}, + {"USD/2", "invalid monetary"}, + {"USD/2 100 extra", "invalid monetary"}, + {"usd 100", "invalid asset"}, + {"USD/2 abc", "invalid monetary amount"}, + } + for _, tc := range errCases { + t.Run("error: "+tc.input, func(t *testing.T) { + _, _, err := ParseMonetary(tc.input) + require.ErrorContains(t, err, tc.msg) + }) + } +} diff --git a/internal/specs_format/compare_movements_test.go b/internal/specs_format/compare_movements_test.go index cfee0034..6b2642a0 100644 --- a/internal/specs_format/compare_movements_test.go +++ b/internal/specs_format/compare_movements_test.go @@ -12,14 +12,14 @@ func TestCompareMovementsMultiplicity(t *testing.T) { y := Movement{Source: "world", Destination: "b", Asset: "USD", Amount: big.NewInt(1)} // [x, x] must not equal [x, y] - require.False(t, compareMovements(Movements{x, x}, Movements{x, y})) - require.False(t, compareMovements(Movements{x, y}, Movements{x, x})) + require.False(t, CompareMovements(Movements{x, x}, Movements{x, y})) + require.False(t, CompareMovements(Movements{x, y}, Movements{x, x})) // order-independent and multiplicity-exact equality still holds - require.True(t, compareMovements(Movements{x, y}, Movements{y, x})) - require.True(t, compareMovements(Movements{x, x}, Movements{x, x})) + require.True(t, CompareMovements(Movements{x, y}, Movements{y, x})) + require.True(t, CompareMovements(Movements{x, x}, Movements{x, x})) // differing amount on the same key is not equal z := Movement{Source: "world", Destination: "a", Asset: "USD", Amount: big.NewInt(2)} - require.False(t, compareMovements(Movements{x}, Movements{z})) + require.False(t, CompareMovements(Movements{x}, Movements{z})) } diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 0dbe691b..03f8871b 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -169,7 +169,7 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp } meta := mergeAccountsMeta(specs.Meta, testCase.Meta) - mergedBalances := mergeBalances(specs.Balances, testCase.Balances) + mergedBalances := MergeBalances(specs.Balances, testCase.Balances) vars := mergeVars(specs.Vars, testCase.Vars) @@ -263,7 +263,7 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp failedAssertions = runAssertion(failedAssertions, "expect.endBalances", testCase.ExpectEndBalances, - getBalances(result.Postings, balances), + EndBalances(result.Postings, balances), interpreter.CompareBalances, ) } @@ -272,7 +272,7 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp failedAssertions = runAssertion(failedAssertions, "expect.endBalances.include", testCase.ExpectEndBalancesInclude, - getBalances(result.Postings, balances), + EndBalances(result.Postings, balances), interpreter.CompareBalancesIncluding, ) } @@ -281,8 +281,8 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp failedAssertions = runAssertion(failedAssertions, "expect.movements", testCase.ExpectMovements, - getMovements(result.Postings), - compareMovements, + GetMovements(result.Postings), + CompareMovements, ) } @@ -394,7 +394,7 @@ func duplicateAccountMetaErr(dup interpreter.AccountMetadataRow) error { // Merge two balance inputs, deduping by (account, asset, color). // Entries in "inner" override matching entries in "outer". -func mergeBalances(outer interpreter.Balances, inner interpreter.Balances) interpreter.Balances { +func MergeBalances(outer interpreter.Balances, inner interpreter.Balances) interpreter.Balances { merged := interpreter.Balances{} indexByKey := map[string]int{} @@ -436,7 +436,7 @@ type Movements = []Movement // Compare movements as a set: order does not matter. // Each (source, sourceScope, destination, destinationScope, asset, color) tuple // is unique within a Movements list, so we match on that tuple and compare amounts. -func compareMovements(expected Movements, got Movements) bool { +func CompareMovements(expected Movements, got Movements) bool { if len(expected) != len(got) { return false } @@ -465,7 +465,8 @@ func compareMovements(expected Movements, got Movements) bool { return true } -func getMovements(postings []interpreter.Posting) Movements { +// GetMovements folds postings into one movement per (source, destination, asset, color). +func GetMovements(postings []interpreter.Posting) Movements { movements := Movements{} for _, posting := range postings { @@ -500,7 +501,8 @@ func getMovements(postings []interpreter.Posting) Movements { return movements } -func getBalances(postings []interpreter.Posting, initialBalances interpreter.Balances) interpreter.Balances { +// EndBalances applies postings to the initial balances, yielding the end state. +func EndBalances(postings []interpreter.Posting, initialBalances interpreter.Balances) interpreter.Balances { // Working set keyed by (account, scope) for O(1)-ish lookups. balances := map[interpreter.AccountAddress][]interpreter.AccountBalance{} diff --git a/internal/typecheck/typecheck.go b/internal/typecheck/typecheck.go new file mode 100644 index 00000000..afbcba8e --- /dev/null +++ b/internal/typecheck/typecheck.go @@ -0,0 +1,447 @@ +// Package typecheck is the shared, side-effect-free type checker for numscript. +// It synthesizes the (base) type of every expression, resolves variable types +// from their declarations, and reports type/name/arity errors — fault-tolerantly +// (it collects all errors instead of bailing, using TypeAny to avoid cascades). +// +// It is meant to be the single source of truth for typing, consumed both by the +// analysis module (LSP/CI) and by the compiler. It intentionally does NOT do +// asset-identity inference, feature-version gating, or lint-style warnings — +// those stay in the analysis module. +package typecheck + +import ( + "fmt" + "slices" + "strings" + + "github.com/formancehq/numscript/internal/builtins" + "github.com/formancehq/numscript/internal/parser" +) + +type Type = string + +const ( + TypeNumber Type = "number" + TypeString Type = "string" + TypeAsset Type = "asset" + TypeMonetary Type = "monetary" + TypeAccount Type = "account" + TypePortion Type = "portion" + + // TypeAny is the type of an expression whose type couldn't be determined + // (e.g. an unbound variable or an unknown function). It's compatible with + // everything, so it suppresses cascading errors. + TypeAny Type = "any" +) + +// order mirrors analysis.AllowedTypes so the InvalidType message reads identically +var allowedTypes = []Type{TypeMonetary, TypeAccount, TypePortion, TypeAsset, TypeNumber, TypeString} + +func isTypeAllowed(t string) bool { return slices.Contains(allowedTypes, t) } + +// --- errors + +// Severity mirrors the LSP DiagnosticSeverity spec (and analysis.Severity, an +// alias of byte too) so an ErrorKind directly satisfies analysis.DiagnosticKind. +type Severity = byte + +const severityError Severity = 1 + +// ErrorKind is both a typecheck error and a renderable diagnostic (Message + +// Severity), so callers can push it as a diagnostic without a translation layer. +type ErrorKind interface { + errorKind() + Message() string + Severity() Severity +} + +type ( + TypeMismatch struct{ Expected, Got string } + UnboundVariable struct{ Name, Type string } + InvalidType struct{ Name string } + BadArity struct{ Expected, Actual int } + // UnknownFunction is either a truly-unknown name (WrongContext == "") or a + // known builtin used in the wrong context (WrongContext is the context it + // belongs to, e.g. "statement"). typecheck only ever emits the former. + UnknownFunction struct{ Name, WrongContext string } + DuplicateVariable struct{ Name string } +) + +func (TypeMismatch) errorKind() {} +func (UnboundVariable) errorKind() {} +func (InvalidType) errorKind() {} +func (BadArity) errorKind() {} +func (UnknownFunction) errorKind() {} +func (DuplicateVariable) errorKind() {} + +func (e TypeMismatch) Message() string { + return fmt.Sprintf("Type mismatch (expected '%s', got '%s' instead)", e.Expected, e.Got) +} + +func (e UnboundVariable) Message() string { + return fmt.Sprintf("The variable '$%s' was not declared", e.Name) +} + +func (e InvalidType) Message() string { + return fmt.Sprintf("'%s' is not a valid type. Allowed types are: %s", e.Name, strings.Join(allowedTypes, ", ")) +} + +func (e BadArity) Message() string { + return fmt.Sprintf("Wrong number of arguments (expected %d, got %d instead)", e.Expected, e.Actual) +} + +func (e UnknownFunction) Message() string { + if e.WrongContext != "" { + return fmt.Sprintf("You cannot use this function here (try to use it in a %s context)", e.WrongContext) + } + return fmt.Sprintf("The function '%s' does not exist", e.Name) +} + +func (e DuplicateVariable) Message() string { + return fmt.Sprintf("A variable with the name '$%s' was already declared", e.Name) +} + +func (TypeMismatch) Severity() Severity { return severityError } +func (UnboundVariable) Severity() Severity { return severityError } +func (InvalidType) Severity() Severity { return severityError } +func (BadArity) Severity() Severity { return severityError } +func (UnknownFunction) Severity() Severity { return severityError } +func (DuplicateVariable) Severity() Severity { return severityError } + +type Error struct { + Range parser.Range + Kind ErrorKind +} + +// --- builtin function signatures + +type fnSig struct { + params []Type + ret Type // "" for statement functions (no return) +} + +var builtinSigs = map[string]fnSig{ + builtins.SetTxMeta: {params: []Type{TypeString, TypeAny}}, + builtins.SetAccountMeta: {params: []Type{TypeAccount, TypeString, TypeAny}}, + builtins.Meta: {params: []Type{TypeAccount, TypeString}, ret: TypeAny}, + builtins.Balance: {params: []Type{TypeAccount, TypeAsset}, ret: TypeMonetary}, + builtins.Overdraft: {params: []Type{TypeAccount, TypeAsset}, ret: TypeMonetary}, + builtins.GetAsset: {params: []Type{TypeMonetary}, ret: TypeAsset}, + builtins.GetAmount: {params: []Type{TypeMonetary}, ret: TypeNumber}, +} + +// --- Result / entrypoint + +type Result struct { + ExprTypes map[parser.ValueExpr]Type + VarTypes map[string]Type + Errors []Error +} + +func Check(program parser.Program) Result { + c := checker{ + exprTypes: map[parser.ValueExpr]Type{}, + varTypes: map[string]Type{}, + declared: map[string]struct{}{}, + } + c.checkProgram(program) + return Result{ExprTypes: c.exprTypes, VarTypes: c.varTypes, Errors: c.errors} +} + +type checker struct { + exprTypes map[parser.ValueExpr]Type + varTypes map[string]Type + declared map[string]struct{} + errors []Error +} + +func (c *checker) push(rng parser.Range, kind ErrorKind) { + c.errors = append(c.errors, Error{Range: rng, Kind: kind}) +} + +func (c *checker) checkProgram(program parser.Program) { + if program.Vars != nil { + for _, varDecl := range program.Vars.Declarations { + if varDecl.Type != nil && !isTypeAllowed(varDecl.Type.Name) { + c.push(varDecl.Type.Range, InvalidType{Name: varDecl.Type.Name}) + } + + if varDecl.Name != nil { + if _, dup := c.declared[varDecl.Name.Name]; dup { + c.push(varDecl.Name.Range, DuplicateVariable{Name: varDecl.Name.Name}) + } else { + c.declared[varDecl.Name.Name] = struct{}{} + if varDecl.Type != nil && isTypeAllowed(varDecl.Type.Name) { + c.varTypes[varDecl.Name.Name] = varDecl.Type.Name + } + } + } + + if varDecl.Origin != nil && varDecl.Type != nil { + c.checkExpr(*varDecl.Origin, varDecl.Type.Name) + } + } + } + + for _, statement := range program.Statements { + c.checkStatement(statement) + } +} + +func (c *checker) checkStatement(statement parser.Statement) { + switch statement := statement.(type) { + case *parser.SaveStatement: + c.checkSentValue(statement.SentValue) + c.checkExpr(statement.Account, TypeAccount) + + case *parser.SendStatement: + c.checkSentValue(statement.SentValue) + c.checkSource(statement.Source) + c.checkDestination(statement.Destination) + + case *parser.FnCall: + c.checkFnCallArity(statement) + } +} + +func (c *checker) checkSentValue(sentValue parser.SentValue) { + switch sentValue := sentValue.(type) { + case *parser.SentValueAll: + c.checkExpr(sentValue.Asset, TypeAsset) + case *parser.SentValueLiteral: + c.checkExpr(sentValue.Monetary, TypeMonetary) + } +} + +func (c *checker) checkSource(source parser.Source) { + if source == nil { + return + } + switch source := source.(type) { + case *parser.SourceAccount: + c.checkExpr(source.ValueExpr, TypeAccount) + c.checkExpr(source.Color, TypeString) + + case *parser.SourceOverdraft: + c.checkExpr(source.Address, TypeAccount) + c.checkExpr(source.Color, TypeString) + if source.Bounded != nil { + c.checkExpr(*source.Bounded, TypeMonetary) + } + + case *parser.SourceWithScaling: + c.checkExpr(source.Address, TypeAccount) + c.checkExpr(source.Through, TypeAccount) + + case *parser.SourceInorder: + for _, sub := range source.Sources { + c.checkSource(sub) + } + + case *parser.SourceOneof: + for _, sub := range source.Sources { + c.checkSource(sub) + } + + case *parser.SourceCapped: + c.checkExpr(source.Cap, TypeMonetary) + c.checkSource(source.From) + + case *parser.SourceAllotment: + for _, item := range source.Items { + if al, ok := item.Allotment.(*parser.ValueExprAllotment); ok { + c.checkExpr(al.Value, TypePortion) + } + c.checkSource(item.From) + } + } +} + +func (c *checker) checkDestination(destination parser.Destination) { + if destination == nil { + return + } + switch destination := destination.(type) { + case *parser.DestinationAccount: + c.checkExpr(destination.ValueExpr, TypeAccount) + + case *parser.DestinationInorder: + for _, clause := range destination.Clauses { + c.checkExpr(clause.Cap, TypeMonetary) + c.checkKeptOrDestination(clause.To) + } + c.checkKeptOrDestination(destination.Remaining) + + case *parser.DestinationOneof: + for _, clause := range destination.Clauses { + c.checkExpr(clause.Cap, TypeMonetary) + c.checkKeptOrDestination(clause.To) + } + c.checkKeptOrDestination(destination.Remaining) + + case *parser.DestinationAllotment: + for _, item := range destination.Items { + if al, ok := item.Allotment.(*parser.ValueExprAllotment); ok { + c.checkExpr(al.Value, TypePortion) + } + c.checkKeptOrDestination(item.To) + } + } +} + +func (c *checker) checkKeptOrDestination(keptOrDest parser.KeptOrDestination) { + if dest, ok := keptOrDest.(*parser.DestinationTo); ok { + c.checkDestination(dest.Destination) + } +} + +// checkExpr synthesizes lit's type, records it, and asserts it matches want. +func (c *checker) checkExpr(lit parser.ValueExpr, want Type) { + got := c.synthType(lit, want) + if want != TypeAny && got != TypeAny && want != got { + c.push(lit.GetRange(), TypeMismatch{Expected: want, Got: got}) + } +} + +// synthType synthesizes lit's type. hint is the type expected by the context; it +// is only used to annotate an unbound-variable error (matching the interpreter's +// diagnostic), never to influence the synthesized type. +func (c *checker) synthType(lit parser.ValueExpr, hint Type) Type { + if lit == nil { + return TypeAny + } + t := c.synthTypeInner(lit, hint) + c.exprTypes[lit] = t + return t +} + +func (c *checker) synthTypeInner(lit parser.ValueExpr, hint Type) Type { + switch lit := lit.(type) { + case *parser.Variable: + t, ok := c.varTypes[lit.Name] + if !ok { + if _, declared := c.declared[lit.Name]; !declared { + c.push(lit.Range, UnboundVariable{Name: lit.Name, Type: hint}) + } + return TypeAny + } + return t + + case *parser.MonetaryLiteral: + c.checkExpr(lit.Asset, TypeAsset) + c.checkExpr(lit.Amount, TypeNumber) + return TypeMonetary + + case *parser.BinaryInfix: + switch lit.Operator { + case parser.InfixOperatorPlus, parser.InfixOperatorMinus: + return c.checkInfixOverload(lit, []Type{TypeNumber, TypeMonetary}) + case parser.InfixOperatorDiv: + c.checkExpr(lit.Left, TypeNumber) + c.checkExpr(lit.Right, TypeNumber) + return TypePortion + default: + c.checkExpr(lit.Left, TypeAny) + c.checkExpr(lit.Right, TypeAny) + return TypeAny + } + + case *parser.Prefix: + switch lit.Operator { + case parser.PrefixOperatorMinus: + return c.checkHasOneOfTypes(lit.Expr, []Type{TypeNumber, TypeMonetary}) + default: + return TypeAny + } + + case *parser.AccountInterpLiteral: + for _, part := range lit.Parts { + if v, ok := part.(*parser.Variable); ok { + c.checkExpr(v, TypeAny) + } + } + return TypeAccount + + case *parser.PercentageLiteral: + return TypePortion + case *parser.AssetLiteral: + return TypeAsset + case *parser.NumberLiteral: + return TypeNumber + case *parser.StringLiteral: + return TypeString + + case *parser.FnCall: + return c.checkFnCall(lit) + + default: + return TypeAny + } +} + +func (c *checker) checkInfixOverload(bin *parser.BinaryInfix, allowed []Type) Type { + leftType := c.synthType(bin.Left, allowed[0]) + if leftType == TypeAny || slices.Contains(allowed, leftType) { + c.checkExpr(bin.Right, leftType) + return leftType + } + c.push(bin.Left.GetRange(), TypeMismatch{Expected: strings.Join(allowed, "|"), Got: leftType}) + return TypeAny +} + +func (c *checker) checkHasOneOfTypes(expr parser.ValueExpr, allowed []Type) Type { + exprType := c.synthType(expr, allowed[0]) + if exprType == TypeAny || slices.Contains(allowed, exprType) { + return exprType + } + c.push(expr.GetRange(), TypeMismatch{Expected: strings.Join(allowed, "|"), Got: exprType}) + return TypeAny +} + +func (c *checker) checkFnCall(fnCall *parser.FnCall) Type { + ret := TypeAny + if sig, ok := builtinSigs[fnCall.Caller.Name]; ok { + ret = sig.ret + if ret == "" { + ret = TypeAny + } + } + c.checkFnCallArity(fnCall) + return ret +} + +func (c *checker) checkFnCallArity(fnCall *parser.FnCall) { + var validArgs []parser.ValueExpr + for _, arg := range fnCall.Args { + if arg != nil { + validArgs = append(validArgs, arg) + } + } + + sig, resolved := builtinSigs[fnCall.Caller.Name] + if !resolved { + for _, arg := range validArgs { + c.checkExpr(arg, TypeAny) + } + c.push(fnCall.Caller.Range, UnknownFunction{Name: fnCall.Caller.Name}) + return + } + + expected := len(sig.params) + actual := len(validArgs) + if actual < expected { + c.push(fnCall.Range, BadArity{Expected: expected, Actual: actual}) + } else if actual > expected { + first := validArgs[expected] + last := validArgs[len(validArgs)-1] + c.push(parser.Range{Start: first.GetRange().Start, End: last.GetRange().End}, + BadArity{Expected: expected, Actual: actual}) + } + + for i, arg := range validArgs { + if i >= len(sig.params) { + break + } + c.checkExpr(arg, sig.params[i]) + } +} diff --git a/internal/typecheck/typecheck_test.go b/internal/typecheck/typecheck_test.go new file mode 100644 index 00000000..5bdd17b8 --- /dev/null +++ b/internal/typecheck/typecheck_test.go @@ -0,0 +1,81 @@ +package typecheck_test + +import ( + "testing" + + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" + + "github.com/stretchr/testify/require" +) + +func check(t *testing.T, src string) typecheck.Result { + t.Helper() + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + return typecheck.Check(parsed.Value) +} + +func kinds(res typecheck.Result) []typecheck.ErrorKind { + out := make([]typecheck.ErrorKind, len(res.Errors)) + for i, e := range res.Errors { + out[i] = e.Kind + } + return out +} + +func TestValidProgram(t *testing.T) { + res := check(t, ` + vars { account $acc = @src } + send [USD/2 10] (source = $acc destination = @dest) + `) + require.Empty(t, res.Errors) + require.Equal(t, typecheck.TypeAccount, res.VarTypes["acc"]) +} + +func TestInvalidType(t *testing.T) { + res := check(t, `vars { invalid $x }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.InvalidType{Name: "invalid"}}, kinds(res)) +} + +func TestDuplicateVariable(t *testing.T) { + res := check(t, `vars { account $x account $x }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.DuplicateVariable{Name: "x"}}, kinds(res)) +} + +func TestUnboundVariable(t *testing.T) { + res := check(t, `send [C 10] (source = $nope destination = @d)`) + require.Equal(t, []typecheck.ErrorKind{typecheck.UnboundVariable{Name: "nope", Type: typecheck.TypeAccount}}, kinds(res)) +} + +func TestTypeMismatch(t *testing.T) { + // a string var used where an account is expected + res := check(t, `vars { string $s } send [C 10] (source = $s destination = @d)`) + require.Equal(t, []typecheck.ErrorKind{ + typecheck.TypeMismatch{Expected: typecheck.TypeAccount, Got: typecheck.TypeString}, + }, kinds(res)) +} + +func TestUnknownFunction(t *testing.T) { + res := check(t, `vars { number $n = nope() }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.UnknownFunction{Name: "nope"}}, kinds(res)) +} + +func TestBadArity(t *testing.T) { + res := check(t, `vars { monetary $m = balance(@a) }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.BadArity{Expected: 2, Actual: 1}}, kinds(res)) +} + +func TestExprTypes(t *testing.T) { + res := check(t, `send [USD/2 10] (source = @a destination = @b)`) + // the monetary literal is typed + send := res // just assert no errors + monetary present via a scan + require.Empty(t, send.Errors) + found := false + for _, ty := range res.ExprTypes { + if ty == typecheck.TypeMonetary { + found = true + } + } + require.True(t, found, "expected a monetary-typed expr") +} diff --git a/internal/vm/execution_err.go b/internal/vm/execution_err.go new file mode 100644 index 00000000..f98a27f4 --- /dev/null +++ b/internal/vm/execution_err.go @@ -0,0 +1,155 @@ +package vm + +import ( + "fmt" + "math/big" +) + +type ( + ExecutionError interface { + error + execErr() + } + + MissingFundsError struct { + Asset string + Needed *big.Int + Got *big.Int + } + + AssetMismatchError struct { + Expected string + Got string + } + + InvalidUncappedSource struct { + Account string + } + + InvalidAllotmentSum struct { + ActualSum big.Rat + } + + MetadataNotFoundError struct { + Account string + Key string + } + + BadMetaValueError struct { + Account string + Key string + Raw string + } + + InvalidAccountName struct { + Name string + } + + InvalidColor struct { + Color string + } + + NegativeBalanceError struct { + Account string + Amount big.Int + } + + DivideByZeroError struct { + Numerator big.Int + } + + // InternalError signals a malformed program the VM cannot execute: a bug in + // whatever produced the bytecode, never a user-script error. Returned rather + // than panicked so the VM never crashes its host. + // + // The mark violations land here rather than getting user-facing error types, + // since both are properties a verifier can decide from the instruction stream + // alone and neither is a legitimate outcome of a well-formed program. + InternalError struct { + Err error + } + + // StoreError wraps an error returned by the host Store (balance or metadata + // fetch): neither a script error nor a bytecode bug, so the wrapped error is + // preserved for the host to inspect. + StoreError struct { + Wrapped error + } +) + +func (e MissingFundsError) Error() string { + return fmt.Sprintf("missing funds for asset %s: needed %s, got %s", e.Asset, e.Needed, e.Got) +} + +func (e AssetMismatchError) Error() string { + return fmt.Sprintf("asset mismatch: expected %s, got %s", e.Expected, e.Got) +} + +func (e InvalidUncappedSource) Error() string { + return fmt.Sprintf("unbounded source is not allowed here: @%s", e.Account) +} + +func (e InternalError) Error() string { + return "internal error: " + e.Err.Error() +} + +func (e InternalError) Unwrap() error { return e.Err } + +func (e DivideByZeroError) Error() string { + return fmt.Sprintf("cannot divide by zero (in %s/0)", e.Numerator.String()) +} + +func (e InvalidAccountName) Error() string { + return fmt.Sprintf("invalid account name: %q", e.Name) +} + +func (e InvalidColor) Error() string { + return fmt.Sprintf("invalid color name: %q", e.Color) +} + +func (e NegativeBalanceError) Error() string { + return fmt.Sprintf("cannot fetch negative balance from account @%s", e.Account) +} + +func (e InvalidAllotmentSum) Error() string { + return fmt.Sprintf("invalid allotment: portions must sum to 1, got %s", e.ActualSum.String()) +} + +func (e MetadataNotFoundError) Error() string { + return fmt.Sprintf("metadata not found: %s[%q]", e.Account, e.Key) +} + +func (e BadMetaValueError) Error() string { + return fmt.Sprintf("invalid metadata value for %s[%q]: %q", e.Account, e.Key, e.Raw) +} + +func (e StoreError) Error() string { return "store error: " + e.Wrapped.Error() } +func (e StoreError) Unwrap() error { return e.Wrapped } + +func (MissingFundsError) execErr() {} +func (AssetMismatchError) execErr() {} +func (InvalidUncappedSource) execErr() {} +func (InvalidAllotmentSum) execErr() {} +func (MetadataNotFoundError) execErr() {} +func (BadMetaValueError) execErr() {} +func (InvalidAccountName) execErr() {} +func (InvalidColor) execErr() {} +func (NegativeBalanceError) execErr() {} +func (DivideByZeroError) execErr() {} +func (InternalError) execErr() {} +func (StoreError) execErr() {} + +var ( + _ ExecutionError = (*MissingFundsError)(nil) + _ ExecutionError = (*AssetMismatchError)(nil) + _ ExecutionError = (*InvalidUncappedSource)(nil) + _ ExecutionError = (*InvalidAllotmentSum)(nil) + _ ExecutionError = (*MetadataNotFoundError)(nil) + _ ExecutionError = (*BadMetaValueError)(nil) + _ ExecutionError = (*InvalidAccountName)(nil) + _ ExecutionError = (*InvalidColor)(nil) + _ ExecutionError = (*NegativeBalanceError)(nil) + _ ExecutionError = (*DivideByZeroError)(nil) + _ ExecutionError = (*InternalError)(nil) + _ ExecutionError = (*StoreError)(nil) +) diff --git a/internal/vm/instruction.go b/internal/vm/instruction.go new file mode 100644 index 00000000..44a76cc9 --- /dev/null +++ b/internal/vm/instruction.go @@ -0,0 +1,243 @@ +package vm + +import "encoding/binary" + +type Instruction struct { + Opcode byte + A byte + B byte + C byte +} + +// Little endian view of the b and c fields +func (i Instruction) GetBC() uint16 { + return uint16(i.B) | uint16(i.C)<<8 +} + +func NewBC( + opcode Opcode, + a byte, + bc uint16, +) Instruction { + var bcBytes [2]byte + binary.LittleEndian.PutUint16(bcBytes[:], bc) + + return Instruction{ + Opcode: byte(opcode), + A: a, + B: bcBytes[0], + C: bcBytes[1], + } +} + +type Opcode byte + +// Opcodes are grouped by category with gaps, so new instructions can be added to +// a category without renumbering. See instruction-encoding.md. +const ( + // --- state & assertions (0x00) --- + Op_SetCurrentAsset Opcode = 0x00 + + Op_AssertSameAsset Opcode = 0x01 + + // errors if the account name in str reg A is not well-formed + Op_AssertValidAccount Opcode = 0x02 + + // errors (NegativeBalanceError) if the amount in int reg A is negative; + // B = account str reg (for the error) + Op_AssertNonNegativeBalance Opcode = 0x03 + + // checks the allotment leftover portion in reg A: errors if negative (portions + // summing to > 1), and — when B == 1 (no `remaining` clause) — if non-zero + Op_AssertLeftover Opcode = 0x04 + + Op_CheckEnoughFunds Opcode = 0x05 + + // errors if the color in str reg A is not well-formed + Op_AssertValidColor Opcode = 0x06 + + // --- constants & variables (0x10) --- + // may split into one opcode per expr_typ later + Op_LoadInt Opcode = 0x10 // LoadConst (`Int) -> b_c = const-pool index + Op_LoadStr Opcode = 0x11 // LoadConst (`String) -> b_c = const-pool index + + Op_LoadVarInt Opcode = 0x12 // b_c = int-var index + Op_LoadVarStr Opcode = 0x13 // b_c = string-var index + + // 0x14 Op_LoadIntImmediate: inline i16 literal in b_c. NOT IMPLEMENTED (reserved) + + // A = dest (bool reg); one opcode per constant, so there is no operand to decode + Op_ConstTrue Opcode = 0x15 + Op_ConstFalse Opcode = 0x16 + + // --- metadata (0x20) --- + // A = key (str reg), B = value (str reg) + Op_SetTxMeta Opcode = 0x20 + + // A = account (str reg), B = key (str reg), C = value (str reg) + Op_SetAccountMeta Opcode = 0x21 + + // meta(account, key) read, dispatched on the target type. + // A = dest, B = account (str reg), C = key (str reg) + Op_MetaStr Opcode = 0x22 + Op_MetaInt Opcode = 0x23 + Op_MetaPortion Opcode = 0x24 + + // as above, but a monetary needs two destinations, so the amount's goes in an + // ext word: A = dest asset (str reg), ext.A = dest amount (int reg) + Op_MetaMonetary Opcode = 0x25 + + // --- arithmetic & constructors (0x30) --- + Op_AddInt Opcode = 0x30 + Op_SubInt Opcode = 0x31 + // 0x32 was Op_MinInt: a min is a comparison and a copy, so it is Op_LtInt + // plus a branch. Reserved, do not reuse. + Op_SubPortion Opcode = 0x33 + Op_MkPortion Opcode = 0x34 + // 0x35 was Op_MkMonetary: a monetary is a (str asset, int amount) register + // pair, so there is nothing to construct. Reserved, do not reuse. + Op_AddString Opcode = 0x36 + + // 0x37 was Op_StrEq: moved to the comparison group, now 0x62. Reserved, do + // not reuse. + + // not adjacent to Op_SubPortion (0x33) because 0x32 is burned and 0x34..0x37 + // are taken + Op_AddPortion Opcode = 0x38 + + // an allotment share is a mul plus a floor (Op_PortionToInt) + Op_MulPortion Opcode = 0x39 + + // 0x3A..0x3F reserved + + // --- unary & conversions (0x40) --- + // 0x40 was Op_GetAmount and 0x41 was Op_GetAsset: projecting a monetary is now + // just naming one of its two registers. Reserved, do not reuse. + // + // One copy per register bank: A = dest, B = src, both in that bank. There is no + // monetary copy — a monetary is a (str asset, int amount) pair, so copy the two + // halves. The family is split across 0x42..0x43 and 0x4A..0x4B because + // 0x44..0x49 were already spoken for. + Op_IntCopy Opcode = 0x42 + Op_PortionCopy Opcode = 0x43 + + Op_NegInt Opcode = 0x44 + Op_IntToString Opcode = 0x45 + Op_PortionToString Opcode = 0x46 + + // A = dest (str reg), B = asset (str reg), C = amount (int reg) + Op_MonetaryToString Opcode = 0x47 + + // 0x48 was Op_IsZero: moved to the comparison group, now 0x63. Reserved, do + // not reuse. + + // 0x49 was Op_Not: moved to the bool-ops group, now 0x70. Reserved, do not + // reuse. + + // the other two bank copies; see Op_IntCopy above + Op_StrCopy Opcode = 0x4A + Op_BoolCopy Opcode = 0x4B + + // Op_IntToPortion is exact; Op_PortionToInt floors. + Op_IntToPortion Opcode = 0x4C + Op_PortionToInt Opcode = 0x4D + + // 0x4E..0x4F reserved + + // --- funds & postings (0x50) --- + + // The most general form: account,cap,overdraft,color + // The 0xFF special register means NULL for cap,overdraft and color + Op_PullAccount Opcode = 0x50 + + // account?, cap?, color? + Op_SendToAccount Opcode = 0x51 + + // save: reduce balance of account A for asset B by amount C (C == nilReg => + // save all), floored at 0 + Op_Save Opcode = 0x52 + + // 0x53 was Op_MkAllotment: an allotment share is now built out of pure ops + // (Op_IntToPortion, Op_MulPortion, Op_PortionToInt plus the leftover fixup), + // so there is no variadic domain instruction. Reserved, do not reuse. + + // reads the account balance from the run-state + Op_Balance Opcode = 0x54 + + // --- marks (oneof backtracking) --- + // + // 0x55 was Op_Snapshot and 0x56 was Op_Restore: the source-queue mark used to + // travel through an int register, which let any int be passed to a restore. The + // pair below takes no register; the mark lives on a LIFO owned by the run-state. + // + // There is no "rewind but keep the mark" opcode: a retry is Op_MarkEnd with the + // rewind flag followed by a fresh Op_MarkPush, so pushes and ends match strictly + // and mark depth is a function of position in the instruction stream. A future + // IR verifier can therefore prove pushes and ends balance and that no + // Op_SendToAccount / Op_SetCurrentAsset / Op_Save sits inside a region; until it + // exists the VM enforces that at execution time. + + // opens a region at the current source-queue depth and posting count + Op_MarkPush Opcode = 0x55 + + // A = rewind flag. Always pops the innermost mark; A == 1 additionally repays + // everything pulled and reverses everything posted since the matching + // Op_MarkPush, while A == 0 commits it. + Op_MarkEnd Opcode = 0x56 + + // reserved (0x57..0x5F) for PullAccount specializations, e.g.: + // // cap=None, overdraft=BoundedZero + // Op_PullAccountBoundedZero + // // cap=None, overdraft=Bounded r + // Op_PullAccountOverdraft + // // cap=Some, overdraft=BoundedZero + // Op_PullAccountCap + // // cap=Some, overdraft=Unbounded + // Op_PullAccountUnboundedOverdraft + // + // This block used to run to 0x8F; the comparison and bool-ops groups below took + // 0x60..0x7F out of it. + + // --- comparisons (0x60) --- + // A = dest (bool reg) for all of them; the operand banks are what the opcode + // implies. Op_IsZero is unary and the rest binary, but they are one group + // because they are the whole set of bool *producers*. + // + // Only `<` and `==` exist, per type. The other surface operators are normalised + // by the front end: + // + // a < b -> Lt(a, b) + // a > b -> Lt(b, a) operands swapped + // a <= b -> Not(Lt(b, a)) + // a >= b -> Not(Lt(a, b)) + // a == b -> Eq(a, b) + // a != b -> Not(Eq(a, b)) + Op_LtInt Opcode = 0x60 + Op_EqInt Opcode = 0x61 + Op_StrEq Opcode = 0x62 // was 0x37 + Op_IsZero Opcode = 0x63 // was 0x48 + Op_LtPortion Opcode = 0x64 + Op_EqPortion Opcode = 0x65 + + // reserved (0x66..0x6F) for `<` and `==` on types that don't exist yet. Str gets + // equality only, never ordering. Bool equality and structural comparison of + // tuples/arrays are front-end expansions rather than opcodes. + + // --- bool ops (0x70) --- + // A = dest (bool reg), B = src (bool reg). + Op_Not Opcode = 0x70 // was 0x49 + + // reserved (0x71..0x7F) for and/or; both are expressible as branches, so + // neither is needed for completeness + + // --- control flow (0x90) --- + // A = cond (bool reg); b_c = unsigned forward delta, added to the pc of the + // next instruction. A quantity is not a condition: project it with Op_IsZero. + Op_JmpIfFalse Opcode = 0x90 + // unconditional; b_c = unsigned forward delta, as above + Op_Jmp Opcode = 0x91 + // the dual of Op_JmpIfFalse, so either edge of a bool can be the branch without + // a negation instruction + Op_JmpIfTrue Opcode = 0x92 + // Label emits no instruction; it only feeds the symbol table at assemble time +) diff --git a/internal/vm/ir_test.go b/internal/vm/ir_test.go new file mode 100644 index 00000000..01ddce87 --- /dev/null +++ b/internal/vm/ir_test.go @@ -0,0 +1,1271 @@ +package vm_test + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/ir" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +// These tests drive the VM from the IR textual format, without the compiler, so +// they can also cover instruction sequences the compiler doesn't emit. + +// irStore is a vm.Store backed by plain maps. A non-nil err fails every lookup. +type irStore struct { + balances map[runtime.PairKey]*big.Int + metadata map[string]map[string]string + err error +} + +func (s irStore) GetBalance(_ context.Context, account, asset, color string) (*big.Int, error) { + if s.err != nil { + return nil, s.err + } + if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + return new(big.Int).Set(v), nil + } + return new(big.Int), nil +} + +func (s irStore) GetMetadata(_ context.Context, account, key string) (string, bool, error) { + if s.err != nil { + return "", false, s.err + } + v, ok := s.metadata[account][key] + return v, ok, nil +} + +func meta(rows map[string]map[string]string) irStore { + return irStore{metadata: rows} +} + +func balances(pairs map[string]int64) irStore { + b := map[runtime.PairKey]*big.Int{} + for account, amount := range pairs { + b[runtime.PairKey{Account: account, Asset: "USD/2"}] = big.NewInt(amount) + } + return irStore{balances: b} +} + +// allot2IR is the sequence the compiler emits to split an amount two ways: +// floor each share, then hand the flooring leftover to the earliest. There is +// no allotment instruction — the split is built out of pure ops — so the three +// tests that need one share this rather than spelling it out each time. +// +// Only one fixup block: flooring loses under a unit per share, so with the two +// portions summing to 1 the shortfall is at most 1 and the second share never +// receives it. +func allot2IR(amount, portion1, portion2, share1, share2 string) string { + return fmt.Sprintf(` + $allot_amt = int_to_portion($%[1]s) + $allot_prod = mul_portion($%[2]s, $allot_amt) + $%[4]s = portion_to_int($allot_prod) + $allot_total = int_copy($%[4]s) + $allot_prod = mul_portion($%[3]s, $allot_amt) + $%[5]s = portion_to_int($allot_prod) + $allot_total = add_int($allot_total, $%[5]s) + $allot_one = 1 + $allot_short = lt_int($allot_total, $%[1]s) + jmp_if_false($allot_short, #allot_end) + $%[4]s = add_int($%[4]s, $allot_one) + $allot_total = add_int($allot_total, $allot_one) +#allot_end +`, amount, portion1, portion2, share1, share2) +} + +// assembleIR turns an IR text into a runnable program, failing the test on any +// error the format's own layers report. +func assembleIR(t *testing.T, src string) vm.Program { + t.Helper() + + instrs, errs := ir.Parse(src) + require.Empty(t, errs, "IR errors: %v", errs) + require.NoError(t, ir.Typecheck(instrs)) + + program, err := ir.Assemble(instrs) + require.NoError(t, err) + return program +} + +// runIR assembles and runs an IR text, requiring it to succeed. +func runIR(t *testing.T, src string, store irStore, vars *vm.Vars) runtime.ExecutionResult { + t.Helper() + + res, execErr := vm.Exec(context.Background(), vm.NewVm(assembleIR(t, src)), vars, store) + require.Nil(t, execErr, "unexpected execution error: %v", execErr) + return res +} + +// runIRExpectingError is runIR for the cases that must fail at run time. +func runIRExpectingError(t *testing.T, src string, store irStore, vars *vm.Vars) vm.ExecutionError { + t.Helper() + + _, execErr := vm.Exec(context.Background(), vm.NewVm(assembleIR(t, src)), vars, store) + require.NotNil(t, execErr, "expected an execution error") + return execErr +} + +func requirePostings(t *testing.T, want, got []runtime.Posting) { + t.Helper() + + require.Len(t, got, len(want)) + for i := range want { + w, g := want[i], got[i] + require.Equal(t, w.Source, g.Source, "posting[%d].Source", i) + require.Equal(t, w.Destination, g.Destination, "posting[%d].Destination", i) + require.Equal(t, w.Asset, g.Asset, "posting[%d].Asset", i) + require.Equal(t, w.Color, g.Color, "posting[%d].Color", i) + require.Zero(t, g.Amount.Cmp(w.Amount), "posting[%d].Amount: got %s, want %s", i, g.Amount, w.Amount) + } +} + +func posting(source, destination string, amount int64) runtime.Posting { + return runtime.Posting{Source: source, Destination: destination, Asset: "USD/2", Amount: big.NewInt(amount)} +} + +func TestIRSend(t *testing.T) { + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 10 + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = "dest" + send_to_account(account: $dest) +`, balances(map[string]int64{"src": 100}), nil) + + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) +} + +// The `max [USD/2 20] from @src` shape: the cap is the smaller of the two. There +// is no min opcode — it is lt_int plus a branch, so both arms need covering, and +// the ties too (lt_int is strict). +func TestIRSourceCappedByMin(t *testing.T) { + // $cap = min($max, $amount), by copying $max and overwriting it unless it + // already won + src := ` + $asset = "USD/2" + set_current_asset($asset) + $amount = load_var(0) + $max = load_var(1) + $cap = int_copy($max) + $lt = lt_int($max, $amount) + jmp_if_true($lt, #min_end) + $cap = int_copy($amount) +#min_end + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $cap, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +` + + testCases := []struct { + name string + amount, max int64 + wantSent int64 + }{ + {"right operand is smaller", 20, 50, 20}, + {"left operand is smaller", 50, 20, 20}, + {"equal operands", 20, 20, 20}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + vars := &vm.Vars{IntsPool: []big.Int{*big.NewInt(tc.amount), *big.NewInt(tc.max)}} + res := runIR(t, src, balances(map[string]int64{"src": 100}), vars) + requirePostings(t, []runtime.Posting{posting("src", "dest", tc.wantSent)}, res.Postings) + }) + } +} + +// A comparison drives a real branch end to end, including the `!=` spelling that +// has no opcode of its own (eq_int + not). +func TestIRComparisonBranch(t *testing.T) { + // send the whole balance only when it differs from the requested amount, + // otherwise send the amount — a shape numscript can't express yet, which is + // the point of testing it here + src := ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + $amount = 10 + $bal = balance($src, $asset) + $same = eq_int($bal, $amount) + $differs = not($same) + $cap = int_copy($amount) + jmp_if_false($differs, #end) + $cap = int_copy($bal) +#end + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $cap, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +` + + t.Run("balance differs, so it is sent whole", func(t *testing.T) { + res := runIR(t, src, balances(map[string]int64{"src": 4}), nil) + requirePostings(t, []runtime.Posting{posting("src", "dest", 4)}, res.Postings) + }) + + t.Run("balance equals the amount, so the amount is sent", func(t *testing.T) { + res := runIR(t, src, balances(map[string]int64{"src": 10}), nil) + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) + }) +} + +func TestIRInorderSourcesStopAtFirstThatCovers(t *testing.T) { + // @a holds enough, so the forward jump must skip @b entirely + src := ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 10 + $pulled = 0 + $remaining = int_copy($amount) + $a = "a" + $overdraft = 0 + $from_a = pull_account(account: $a, cap: $remaining, overdraft: $overdraft) + $pulled += $from_a + $remaining -= $from_a + $exhausted = is_zero($remaining) + jmp_if_true($exhausted, #inorder_end) + $b = "b" + $from_b = pull_account(account: $b, cap: $remaining, overdraft: $overdraft) + $pulled += $from_b +#inorder_end + check_enough_funds($pulled, $amount) + $dest = "dest" + send_to_account(account: $dest) +` + + t.Run("first source covers it", func(t *testing.T) { + res := runIR(t, src, balances(map[string]int64{"a": 100, "b": 100}), nil) + requirePostings(t, []runtime.Posting{posting("a", "dest", 10)}, res.Postings) + }) + + t.Run("falls through to the second", func(t *testing.T) { + res := runIR(t, src, balances(map[string]int64{"a": 4, "b": 100}), nil) + requirePostings(t, []runtime.Posting{ + posting("a", "dest", 4), + posting("b", "dest", 6), + }, res.Postings) + }) + + t.Run("neither covers it", func(t *testing.T) { + execErr := runIRExpectingError(t, src, balances(map[string]int64{"a": 4, "b": 3}), nil) + require.IsType(t, vm.MissingFundsError{}, execErr) + }) +} + +func TestIRAllotmentDestination(t *testing.T) { + // 1/4 to @small, the remaining 3/4 to @big + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 100 + $world = "world" + $overdraft = 100 + $pulled = pull_account(account: $world, cap: $amount, overdraft: $overdraft) + $one = 1 + $four = 4 + $quarter = mk_portion($one, $four) + $whole = mk_portion($one, $one) + $leftover = sub_portion($whole, $quarter) + assert_leftover($leftover) +`+allot2IR("amount", "quarter", "leftover", "small_share", "big_share")+` + $small = "small" + send_to_account(account: $small, cap: $small_share) + $big = "big" + send_to_account(account: $big, cap: $big_share) +`, balances(nil), nil) + + requirePostings(t, []runtime.Posting{ + posting("world", "small", 25), + posting("world", "big", 75), + }, res.Postings) +} + +func TestIRBalanceReadFromStore(t *testing.T) { + // send exactly what @src holds, read at run time + res := runIR(t, ` + $src = "src" + $asset = "USD/2" + $bal = balance($src, $asset) + assert_non_negative_balance($bal, $src) + set_current_asset($asset) + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $bal, overdraft: $overdraft) + check_enough_funds($pulled, $bal) + $dest = "dest" + send_to_account(account: $dest) +`, balances(map[string]int64{"src": 42}), nil) + + requirePostings(t, []runtime.Posting{posting("src", "dest", 42)}, res.Postings) +} + +func TestIRUnsentFundsAreReturnedToTheSource(t *testing.T) { + // send_to_account with no account: the `kept` destination. The funds are + // released back and no posting is emitted for them. + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 100 + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + $half = 50 + $dest = "dest" + send_to_account(account: $dest, cap: $half) + send_to_account() +`, balances(map[string]int64{"src": 100}), nil) + + requirePostings(t, []runtime.Posting{posting("src", "dest", 50)}, res.Postings) +} + +func TestIRMarkBacktracks(t *testing.T) { + // the `oneof` shape as the compiler emits it: a region per branch, each failed + // one closed with a rewind and immediately reopened, committed once at the join + src := ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 10 + mark_push() + $a = "a" + $overdraft = 0 + $from_a = pull_account(account: $a, cap: $amount, overdraft: $overdraft) + $result = int_copy($from_a) + $missing = $amount - $from_a + $covered = is_zero($missing) + jmp_if_true($covered, #oneof_end) + mark_rewind() + mark_push() + $b = "b" + $from_b = pull_account(account: $b, cap: $amount, overdraft: $overdraft) + $result = int_copy($from_b) +#oneof_end + mark_commit() + check_enough_funds($result, $amount) + $dest = "dest" + send_to_account(account: $dest) +` + + t.Run("first branch covers it", func(t *testing.T) { + res := runIR(t, src, balances(map[string]int64{"a": 10, "b": 10}), nil) + requirePostings(t, []runtime.Posting{posting("a", "dest", 10)}, res.Postings) + }) + + t.Run("rewinds to the second branch", func(t *testing.T) { + // @a can only cover part of it, so its partial funding must be discarded + res := runIR(t, src, balances(map[string]int64{"a": 3, "b": 10}), nil) + requirePostings(t, []runtime.Posting{posting("b", "dest", 10)}, res.Postings) + }) +} + +// A rewind must undo only what its own region pulled: funds queued before the +// mark_push survive it and are still sendable afterwards. +func TestIRMarkRewindKeepsFundsQueuedBeforeThePush(t *testing.T) { + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $overdraft = 0 + $keep_amt = 4 + $kept = "kept" + $from_kept = pull_account(account: $kept, cap: $keep_amt, overdraft: $overdraft) + mark_push() + $spec_amt = 7 + $spec = "spec" + $from_spec = pull_account(account: $spec, cap: $spec_amt, overdraft: $overdraft) + mark_rewind() + $dest = "dest" + send_to_account(account: $dest) +`, balances(map[string]int64{"kept": 100, "spec": 100}), nil) + + // only the pre-mark pull reaches the destination; @spec was repaid + requirePostings(t, []runtime.Posting{posting("kept", "dest", 4)}, res.Postings) +} + +// Nested regions must rewind independently: the inner one leaves the outer one's +// funds alone, and the outer rewind then discards everything. +func TestIRMarkNestedRegions(t *testing.T) { + src := ` + $asset = "USD/2" + set_current_asset($asset) + $overdraft = 0 + $ten = 10 + mark_push() + $outer = "outer" + $from_outer = pull_account(account: $outer, cap: $ten, overdraft: $overdraft) + mark_push() + $inner = "inner" + $from_inner = pull_account(account: $inner, cap: $ten, overdraft: $overdraft) + mark_rewind() +` + balances := balances(map[string]int64{"outer": 100, "inner": 100}) + + t.Run("outer region commits", func(t *testing.T) { + res := runIR(t, src+` + mark_commit() + $dest = "dest" + send_to_account(account: $dest) +`, balances, nil) + // the inner rewind dropped @inner; @outer survived it and is committed + requirePostings(t, []runtime.Posting{posting("outer", "dest", 10)}, res.Postings) + }) + + t.Run("outer region rewinds too", func(t *testing.T) { + res := runIR(t, src+` + mark_rewind() + $dest = "dest" + send_to_account(account: $dest) +`, balances, nil) + requirePostings(t, []runtime.Posting{}, res.Postings) + }) +} + +// A mark op with nothing to act on is a malformed program, not a script outcome: +// it is a bug in whatever produced the bytecode, so it surfaces as an +// InternalError. The point is that it never panics — the old index-valued restore +// truncated the source queue to an arbitrary int, which panicked out of range or, +// worse, resurrected already-consumed entries. +func TestIRMarkWithNoOpenRegionIsAnInternalError(t *testing.T) { + cases := map[string]string{ + "rewind with no push": ` + mark_rewind() +`, + "commit with no push": ` + mark_commit() +`, + "one push, two ends": ` + mark_push() + mark_commit() + mark_commit() +`, + "rewind after the region closed": ` + mark_push() + mark_commit() + mark_rewind() +`, + // a rewind closes too, so a second end has nothing left to act on + "rewind then commit": ` + mark_push() + mark_rewind() + mark_commit() +`, + } + + for name, src := range cases { + t.Run(name, func(t *testing.T) { + execErr := runIRExpectingError(t, src, balances(nil), nil) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "no open mark") + }) + } +} + +// A mark is a source-queue depth, so it only means anything while nothing drains +// the queue from the front and the asset a repay lands on is fixed. All three ops +// that would break it are rejected inside a region rather than silently corrupting +// balances. Compiled numscript never emits any of them inside one — sources only +// pull, and `save` is a statement — so this is reachable only from hand-written IR +// (or a hand-crafted .numb), and it is exactly what a mark-depth verifier would +// reject statically. +func TestIRSendAndSetAssetAreRejectedInsideARegion(t *testing.T) { + prelude := ` + $asset = "USD/2" + set_current_asset($asset) + $overdraft = 0 + $ten = 10 + $src = "src" + mark_push() + $pulled = pull_account(account: $src, cap: $ten, overdraft: $overdraft) +` + store := balances(map[string]int64{"src": 100}) + + t.Run("send inside a region", func(t *testing.T) { + execErr := runIRExpectingError(t, prelude+` + $dest = "dest" + send_to_account(account: $dest) +`, store, nil) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "send while a mark is open") + }) + + t.Run("uncapped send inside a region", func(t *testing.T) { + execErr := runIRExpectingError(t, prelude+` + send_to_account() +`, store, nil) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "send while a mark is open") + }) + + t.Run("set_current_asset inside a region", func(t *testing.T) { + execErr := runIRExpectingError(t, prelude+` + $other = "EUR/2" + set_current_asset($other) +`, store, nil) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "set_current_asset while a mark is open") + }) + + // save reduces a balance, and a rewind only repays queued sources — so a save in + // an abandoned branch would persist. It is the one op on this list that stays + // forbidden no matter how much rollback is added later: its floor at zero is not + // invertible from a delta. + t.Run("save inside a region", func(t *testing.T) { + execErr := runIRExpectingError(t, prelude+` + $five = 5 + save(account: $src, asset: $asset, amount: $five) +`, store, nil) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "save while a mark is open") + }) + + t.Run("save-all inside a region", func(t *testing.T) { + execErr := runIRExpectingError(t, prelude+` + save(account: $src, asset: $asset) +`, store, nil) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "save while a mark is open") + }) + + // all three are fine once the region has closed, so the check is scoped to the + // region and not a blanket ban + t.Run("all three are allowed after the region closes", func(t *testing.T) { + res := runIR(t, prelude+` + mark_commit() + $dest = "dest" + send_to_account(account: $dest) + $five = 5 + save(account: $src, asset: $asset, amount: $five) + $other = "EUR/2" + set_current_asset($other) +`, store, nil) + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) + }) +} + +// Interleaving pulls with mark ops across a jump: the region spans a branch, and +// both paths through it reach the same single mark_commit. This is the shape the +// compiler emits, and the reason mark depth stays a function of position. +func TestIRMarkAcrossAJump(t *testing.T) { + src := ` + $asset = "USD/2" + set_current_asset($asset) + $overdraft = 0 + $ten = 10 + $zero = 0 + mark_push() + $a = "a" + $from_a = pull_account(account: $a, cap: $ten, overdraft: $overdraft) + $took_nothing = is_zero($from_a) + jmp_if_false($took_nothing, #done) + $b = "b" + $from_b = pull_account(account: $b, cap: $ten, overdraft: $overdraft) +#done + mark_commit() + $dest = "dest" + send_to_account(account: $dest) +` + + t.Run("branch taken", func(t *testing.T) { + // @a is empty, so the jump falls through to the @b pull + res := runIR(t, src, balances(map[string]int64{"a": 0, "b": 10}), nil) + requirePostings(t, []runtime.Posting{posting("b", "dest", 10)}, res.Postings) + }) + + t.Run("branch skipped", func(t *testing.T) { + res := runIR(t, src, balances(map[string]int64{"a": 10, "b": 10}), nil) + requirePostings(t, []runtime.Posting{posting("a", "dest", 10)}, res.Postings) + }) +} + +// A run that dies inside a region must not leak the open mark into the next run +// on the same Vm: the reused RunState drops it, so the second run's send works. +func TestIRMarkDoesNotLeakAcrossRuns(t *testing.T) { + program := assembleIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $overdraft = 0 + $ten = 10 + $src = "src" + mark_push() + $pulled = pull_account(account: $src, cap: $ten, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`) + machine := vm.NewVm(program) + store := balances(map[string]int64{"src": 100}) + + // the send inside the region fails, leaving the mark open + _, execErr := vm.Exec(context.Background(), machine, nil, store) + require.IsType(t, vm.InternalError{}, execErr) + + // a well-formed program on the same Vm must not inherit that mark + res, execErr := vm.Exec(context.Background(), vm.NewVm(assembleIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $overdraft = 0 + $ten = 10 + $src = "src" + $pulled = pull_account(account: $src, cap: $ten, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`)), nil, store) + require.Nil(t, execErr) + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) + + // and the same Vm, rerun, is clean too + res, execErr = vm.Exec(context.Background(), machine, nil, store) + require.IsType(t, vm.InternalError{}, execErr) + require.ErrorContains(t, execErr, "send while a mark is open") + require.Empty(t, res.Postings) +} + +func TestIRStrEqAndJmp(t *testing.T) { + // the if/else shape: str_eq is the only way to branch on a string, and jmp is + // what skips the else arm. Here the taken arm decides which account is pulled + // from, which is how @world's unboundedness is expressed in bytecode. + src := ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 10 + $overdraft = 0 + $expected = "yes" + $probe = load_var(0) + $eq = str_eq($probe, $expected) + jmp_if_false($eq, #else) + $then_acc = "a" + $pulled = pull_account(account: $then_acc, cap: $amount, overdraft: $overdraft) + jmp(#end) +#else + $else_acc = "b" + $pulled = pull_account(account: $else_acc, cap: $amount, overdraft: $overdraft) +#end + $dest = "dest" + send_to_account(account: $dest) +` + + store := balances(map[string]int64{"a": 10, "b": 10}) + + t.Run("equal strings take the then arm", func(t *testing.T) { + res := runIR(t, src, store, &vm.Vars{StringsPool: []string{"yes"}}) + requirePostings(t, []runtime.Posting{posting("a", "dest", 10)}, res.Postings) + }) + + t.Run("and jmp skips it otherwise", func(t *testing.T) { + res := runIR(t, src, store, &vm.Vars{StringsPool: []string{"no"}}) + requirePostings(t, []runtime.Posting{posting("b", "dest", 10)}, res.Postings) + }) +} + +func TestIRSaveWithholdsFunds(t *testing.T) { + // save reserves part of the balance, so the pull can't reach it + src := ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + $reserved = 30 + save(account: $src, asset: $asset, amount: $reserved) + $amount = 100 + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +` + + res := runIR(t, src, balances(map[string]int64{"src": 100}), nil) + requirePostings(t, []runtime.Posting{posting("src", "dest", 70)}, res.Postings) +} + +func TestIROverdraftAllowsNegativeBalance(t *testing.T) { + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 40 + $src = "src" + $overdraft = 25 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = "dest" + send_to_account(account: $dest) +`, balances(map[string]int64{"src": 15}), nil) + + // 15 on the account plus 25 of allowed overdraft + requirePostings(t, []runtime.Posting{posting("src", "dest", 40)}, res.Postings) +} + +func TestIRMetadata(t *testing.T) { + res := runIR(t, ` + $account = "acc" + $key = "k" + $value = "v" + set_account_meta($account, $key, $value) + $tx_key = "tx" + $tx_value = "yes" + set_tx_meta($tx_key, $tx_value) +`, balances(nil), nil) + + require.Equal(t, map[string]string{"tx": "yes"}, res.Metadata) + require.Equal(t, runtime.AccountsMetadata{"acc": {"k": "v"}}, res.AccountsMetadata) +} + +func TestIRReadsMetadataFromStore(t *testing.T) { + // the amount to send is an int read out of @src's metadata + store := irStore{ + balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2"}: big.NewInt(100), + }, + metadata: map[string]map[string]string{ + "src": {"quota": "7"}, + }, + } + + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + $key = "quota" + $amount = meta($src, $key) + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = "dest" + send_to_account(account: $dest) +`, store, nil) + + requirePostings(t, []runtime.Posting{posting("src", "dest", 7)}, res.Postings) +} + +func TestIRMissingMetadataIsAnError(t *testing.T) { + execErr := runIRExpectingError(t, ` + $src = "src" + $key = "nope" + $value = meta($src, $key) +`, balances(nil), nil) + + require.IsType(t, vm.MetadataNotFoundError{}, execErr) +} + +func TestIRLoadsVars(t *testing.T) { + // vars come in as pools, indexed by the load_var instructions + vars := &vm.Vars{ + StringsPool: []string{"USD/2", "src", "dest"}, + IntsPool: []big.Int{*big.NewInt(10), *big.NewInt(0)}, + } + + res := runIR(t, ` + $asset = load_var(0) + set_current_asset($asset) + $amount = load_var(0) + $src = load_var(1) + $overdraft = load_var(1) + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = load_var(2) + send_to_account(account: $dest) +`, balances(map[string]int64{"src": 100}), vars) + + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) +} + +func TestIRAssertions(t *testing.T) { + t.Run("invalid account name", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $account = "not a valid account!" + assert_valid_account($account) +`, balances(nil), nil) + require.IsType(t, vm.InvalidAccountName{}, execErr) + }) + + t.Run("invalid color", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $color = "not a color" + assert_valid_color($color) +`, balances(nil), nil) + require.IsType(t, vm.InvalidColor{}, execErr) + }) + + t.Run("empty color is valid", func(t *testing.T) { + res := runIR(t, ` + $color = "" + assert_valid_color($color) +`, balances(nil), nil) + require.Empty(t, res.Postings) + }) + + t.Run("mismatched assets", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $usd = "USD/2" + $eur = "EUR/2" + assert_same_asset($usd, $eur) +`, balances(nil), nil) + require.IsType(t, vm.AssetMismatchError{}, execErr) + }) + + t.Run("negative balance", func(t *testing.T) { + store := irStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2"}: big.NewInt(-1), + }} + execErr := runIRExpectingError(t, ` + $src = "src" + $asset = "USD/2" + $bal = balance($src, $asset) + assert_non_negative_balance($bal, $src) +`, store, nil) + require.IsType(t, vm.NegativeBalanceError{}, execErr) + }) + + t.Run("allotment portions over 100%", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $one = 1 + $two = 2 + $half = mk_portion($one, $two) + $whole = mk_portion($one, $one) + $leftover = sub_portion($whole, $half) + $negative = sub_portion($leftover, $whole) + assert_leftover($negative) +`, balances(nil), nil) + require.IsType(t, vm.InvalidAllotmentSum{}, execErr) + }) +} + +func TestIRUncappedPull(t *testing.T) { + // no cap: the pull is bounded only by the overdraft, i.e. `send *` + t.Run("with an overdraft", func(t *testing.T) { + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`, balances(map[string]int64{"src": 70}), nil) + + requirePostings(t, []runtime.Posting{posting("src", "dest", 70)}, res.Postings) + }) + + t.Run("without one it is unbounded and rejected", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + $pulled = pull_account(account: $src) +`, balances(map[string]int64{"src": 70}), nil) + + require.IsType(t, vm.InvalidUncappedSource{}, execErr) + }) +} + +func TestIRSaveAll(t *testing.T) { + // save with no amount withholds the whole balance, so the pull finds nothing + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + save(account: $src, asset: $asset) + $amount = 100 + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`, balances(map[string]int64{"src": 100}), nil) + + require.Empty(t, res.Postings) +} + +func TestIRAssertLeftoverExact(t *testing.T) { + // the no-`remaining` form: portions must cover exactly 1 + execErr := runIRExpectingError(t, ` + $one = 1 + $two = 2 + $half = mk_portion($one, $two) + $whole = mk_portion($one, $one) + $leftover = sub_portion($whole, $half) + assert_leftover_exact($leftover) +`, balances(nil), nil) + + require.IsType(t, vm.InvalidAllotmentSum{}, execErr) +} + +func TestIRMetaTypes(t *testing.T) { + store := meta(map[string]map[string]string{ + "acc": { + "portion": "1/4", + "monetary": "USD/2 250", + "oops": "not a number", + }, + }) + + t.Run("portion", func(t *testing.T) { + // the portion drives an allotment, so the split proves it parsed + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 100 + $world = "world" + $overdraft = 100 + $pulled = pull_account(account: $world, cap: $amount, overdraft: $overdraft) + $acc = "acc" + $key = "portion" + $quarter = meta($acc, $key) + $one = 1 + $whole = mk_portion($one, $one) + $rest = sub_portion($whole, $quarter) + assert_leftover($rest) +`+allot2IR("amount", "quarter", "rest", "a_share", "b_share")+` + $a = "a" + send_to_account(account: $a, cap: $a_share) + $b = "b" + send_to_account(account: $b, cap: $b_share) +`, store, nil) + + requirePostings(t, []runtime.Posting{ + posting("world", "a", 25), + posting("world", "b", 75), + }, res.Postings) + }) + + t.Run("monetary", func(t *testing.T) { + res := runIR(t, ` + $acc = "acc" + $key = "monetary" + [$asset, $amount] = meta_monetary($acc, $key) + set_current_asset($asset) + $overdraft = 300 + $pulled = pull_account(account: $acc, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = "dest" + send_to_account(account: $dest) +`, store, nil) + + requirePostings(t, []runtime.Posting{posting("acc", "dest", 250)}, res.Postings) + }) + + t.Run("a value of the wrong shape is an error", func(t *testing.T) { + for _, read := range []string{ + ` $v = meta($acc, $key)`, + ` $v = meta($acc, $key)`, + ` [$a, $n] = meta_monetary($acc, $key)`, + } { + execErr := runIRExpectingError(t, ` + $acc = "acc" + $key = "oops" +`+read+"\n", store, nil) + require.IsType(t, vm.BadMetaValueError{}, execErr, "%s", read) + } + }) +} + +func TestIRStoreErrorsPropagate(t *testing.T) { + failing := irStore{err: errors.New("store is down")} + + t.Run("on a balance read", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $src = "src" + $asset = "USD/2" + $bal = balance($src, $asset) +`, failing, nil) + require.IsType(t, vm.StoreError{}, execErr) + require.ErrorContains(t, execErr, "store is down") + }) + + t.Run("on a pull", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 10 + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) +`, failing, nil) + require.IsType(t, vm.StoreError{}, execErr) + }) + + t.Run("on a metadata read", func(t *testing.T) { + for _, read := range []string{ + ` $v = meta($acc, $key)`, + ` $v = meta($acc, $key)`, + ` $v = meta($acc, $key)`, + ` [$a, $n] = meta_monetary($acc, $key)`, + } { + execErr := runIRExpectingError(t, ` + $acc = "acc" + $key = "k" +`+read+"\n", failing, nil) + require.IsType(t, vm.StoreError{}, execErr, "%s", read) + } + }) + + t.Run("on an uncapped pull", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $asset = "USD/2" + set_current_asset($asset) + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, overdraft: $overdraft) +`, failing, nil) + require.IsType(t, vm.StoreError{}, execErr) + }) + + t.Run("on a save", func(t *testing.T) { + execErr := runIRExpectingError(t, ` + $acc = "acc" + $asset = "USD/2" + $amount = 10 + save(account: $acc, asset: $asset, amount: $amount) +`, failing, nil) + require.IsType(t, vm.StoreError{}, execErr) + }) + + t.Run("but not on a send: crediting a destination reads nothing", func(t *testing.T) { + // the pull has no overdraft operand, so it is unbounded and reads no balance + // either — the whole send runs against a store that fails every call. This + // is the arm the compiler emits for @world. + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $world = "world" + $amount = 10 + $pulled = pull_account(account: $world, cap: $amount) + $dest = "dest" + send_to_account(account: $dest) +`, failing, nil) + requirePostings(t, []runtime.Posting{posting("world", "dest", 10)}, res.Postings) + }) +} + +// The bogus-mark case the old index-valued restore needed a guard for is gone: +// with no operand there is no value to pass, so an out-of-range mark is not +// expressible in the IR at all. Misuse can only be an unbalanced stack, covered by +// TestIRMarkWithNoOpenRegionIsAnInternalError. +func TestIRMarkTakesNoOperand(t *testing.T) { + _, errs := ir.Parse(` + $mark = 3 + mark_rewind($mark) +`) + require.NotEmpty(t, errs, "mark_rewind must not accept an operand") +} + +func TestIRVmIsReusableAcrossRuns(t *testing.T) { + // a second run must not see the first one's funds or postings + program := assembleIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 10 + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = "dest" + send_to_account(account: $dest) +`) + machine := vm.NewVm(program) + store := balances(map[string]int64{"src": 100}) + + want := []runtime.Posting{posting("src", "dest", 10)} + for run := 1; run <= 3; run++ { + res, execErr := vm.Exec(context.Background(), machine, nil, store) + require.Nil(t, execErr, "run %d", run) + requirePostings(t, want, res.Postings) + } +} + +// TestIRSurvivesTheWireFormat runs one program in memory and again after a trip +// through Encode/DecodeProgram. Nothing else ties the encoder to the VM. +// No instruction reads a bool yet, so this only pins down that the bank is +// allocated separately from the others and that the two ops run. +func TestIRConstBool(t *testing.T) { + program := assembleIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $t = true + $f = false + $amount = 10 + $overdraft = 0 + $src = "src" + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`) + + require.Equal(t, byte(2), program.MaxRegBool) + // $amount, $overdraft, $pulled — the two bools are not among them + require.Equal(t, byte(3), program.MaxRegInt, "bools don't consume int registers") + + decoded, err := vm.DecodeProgram(program.Encode()) + require.NoError(t, err) + require.Equal(t, program, decoded) + + res, execErr := vm.Exec(context.Background(), vm.NewVm(decoded), nil, balances(map[string]int64{"src": 10})) + require.Nil(t, execErr, "unexpected execution error: %v", execErr) + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) +} + +func TestIRSurvivesTheWireFormat(t *testing.T) { + program := assembleIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 100 + $src = "src" + $overdraft = 0 + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $one = 1 + $two = 2 + $half = mk_portion($one, $two) + $whole = mk_portion($one, $one) + $rest = sub_portion($whole, $half) + assert_leftover($rest) +`+allot2IR("amount", "half", "rest", "a_share", "b_share")+` + $a = "a" + send_to_account(account: $a, cap: $a_share) + $b = "b" + send_to_account(account: $b, cap: $b_share) +`) + + decoded, err := vm.DecodeProgram(program.Encode()) + require.NoError(t, err) + require.Equal(t, program, decoded, "the program changed shape on the way through") + + want := []runtime.Posting{posting("src", "a", 50), posting("src", "b", 50)} + for name, prog := range map[string]vm.Program{"in memory": program, "decoded": decoded} { + res, execErr := vm.Exec(context.Background(), vm.NewVm(prog), nil, balances(map[string]int64{"src": 100})) + require.Nil(t, execErr, "%s: %v", name, execErr) + requirePostings(t, want, res.Postings) + } +} + +// TestIRVarsSurviveTheWireFormat is the same for the vars payload. +func TestIRVarsSurviveTheWireFormat(t *testing.T) { + vars := vm.Vars{ + StringsPool: []string{"USD/2", "src", "dest"}, + IntsPool: []big.Int{*big.NewInt(10), *big.NewInt(0)}, + } + decoded, err := vm.DecodeVars(vars.Encode()) + require.NoError(t, err) + + program := assembleIR(t, ` + $asset = load_var(0) + set_current_asset($asset) + $amount = load_var(0) + $src = load_var(1) + $overdraft = load_var(1) + $pulled = pull_account(account: $src, cap: $amount, overdraft: $overdraft) + check_enough_funds($pulled, $amount) + $dest = load_var(2) + send_to_account(account: $dest) +`) + + res, execErr := vm.Exec(context.Background(), vm.NewVm(program), &decoded, balances(map[string]int64{"src": 100})) + require.Nil(t, execErr) + requirePostings(t, []runtime.Posting{posting("src", "dest", 10)}, res.Postings) +} + +// --- The int/portion boundary ops ------------------------------------------- + +func TestIRPortionToIntFloors(t *testing.T) { + // 7/2 of nothing in particular: the projection floors, it does not round + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $seven = 7 + $two = 2 + $p = mk_portion($seven, $two) + $amount = portion_to_int($p) + $world = "world" + $overdraft = 100 + $pulled = pull_account(account: $world, cap: $amount, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`, balances(nil), nil) + + requirePostings(t, []runtime.Posting{posting("world", "dest", 3)}, res.Postings) +} + +func TestIRIntToPortionAndMul(t *testing.T) { + // 1/4 * 100 == 25, computed as mul_portion(int_to_portion(100), 1/4) + res := runIR(t, ` + $asset = "USD/2" + set_current_asset($asset) + $hundred = 100 + $one = 1 + $four = 4 + $quarter = mk_portion($one, $four) + $ap = int_to_portion($hundred) + $prod = mul_portion($quarter, $ap) + $amount = portion_to_int($prod) + $world = "world" + $overdraft = 100 + $pulled = pull_account(account: $world, cap: $amount, overdraft: $overdraft) + $dest = "dest" + send_to_account(account: $dest) +`, balances(nil), nil) + + requirePostings(t, []runtime.Posting{posting("world", "dest", 25)}, res.Postings) +} + +// A three-way split written out of pure ops: floor each share, then hand the +// flooring leftover to the earliest shares one unit at a time. This is the +// lowering compileAllotmentSplit emits, pinned here on the 34/33/33 case. +// +// Only n-1 fixup blocks: each floor loses < 1, so with portions summing to 1 the +// shortfall is <= n-1 and the last share never receives a unit. +const allotThirdsIR = ` + $asset = "USD/2" + set_current_asset($asset) + $amount = 100 + $world = "world" + $overdraft = 100 + $pulled = pull_account(account: $world, cap: $amount, overdraft: $overdraft) + + $one = 1 + $three = 3 + $third = mk_portion($one, $three) + $ap = int_to_portion($amount) + + $prod = mul_portion($third, $ap) + $out0 = portion_to_int($prod) + $total = int_copy($out0) + $prod = mul_portion($third, $ap) + $out1 = portion_to_int($prod) + $total = add_int($total, $out1) + $prod = mul_portion($third, $ap) + $out2 = portion_to_int($prod) + $total = add_int($total, $out2) + + $lt = lt_int($total, $amount) + jmp_if_false($lt, #done) + $out0 = add_int($out0, $one) + $total = add_int($total, $one) + $lt = lt_int($total, $amount) + jmp_if_false($lt, #done) + $out1 = add_int($out1, $one) + $total = add_int($total, $one) +#done + + $a = "a" + send_to_account(account: $a, cap: $out0) + $b = "b" + send_to_account(account: $b, cap: $out1) + $c = "c" + send_to_account(account: $c, cap: $out2) +` + +func TestIRAllotmentFromPureOps(t *testing.T) { + res := runIR(t, allotThirdsIR, balances(nil), nil) + + requirePostings(t, []runtime.Posting{ + posting("world", "a", 34), + posting("world", "b", 33), + posting("world", "c", 33), + }, res.Postings) +} diff --git a/internal/vm/meta_test.go b/internal/vm/meta_test.go new file mode 100644 index 00000000..8c317ecc --- /dev/null +++ b/internal/vm/meta_test.go @@ -0,0 +1,56 @@ +package vm + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/stretchr/testify/require" +) + +func TestSetAccountMeta(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), // r_s0 = "acc" + bc(Op_LoadStr, 1, 1), // r_s1 = "k" + bc(Op_LoadStr, 2, 2), // r_s2 = "v" + abc(Op_SetAccountMeta, 0, 1, 2), // set_account_meta(acc, k, v) + }, + StringsPool: []string{"acc", "k", "v"}, + } + + res, execErr := Exec(context.Background(), NewVm(prog), nil, mockStore{}) + require.Nil(t, execErr) + require.Equal(t, runtime.AccountsMetadata{"acc": {"k": "v"}}, res.AccountsMetadata) +} + +func TestMetaStr(t *testing.T) { + // meta("config", "beneficiary") == "alice"; then send [USD/2 100] from world to it. + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), // s0 = "USD/2" + abc(Op_SetCurrentAsset, 0, 0, 0), + bc(Op_LoadStr, 1, 1), // s1 = "config" + bc(Op_LoadStr, 2, 2), // s2 = "beneficiary" + abc(Op_MetaStr, 3, 1, 2), // s3 = meta(config, beneficiary) = "alice" + bc(Op_LoadStr, 4, 3), // s4 = "world" + bc(Op_LoadInt, 0, 0), // i0 = 100 (cap) + abc(Op_PullAccount, 1, 4, 0), // i1 = pull(world, cap i0) + abc(0, nilReg, nilReg, 0), // ext: no overdraft, no color + abc(Op_SendToAccount, 3, nilReg, nilReg), // send to s3 (alice) + }, + StringsPool: []string{"USD/2", "config", "beneficiary", "world"}, + IntsPool: []big.Int{*big.NewInt(100)}, + } + + store := mockStore{meta: map[string]map[string]string{ + "config": {"beneficiary": "alice"}, + }} + + res, execErr := Exec(context.Background(), NewVm(prog), nil, store) + require.Nil(t, execErr) + require.Equal(t, []runtime.Posting{ + {Source: "world", Destination: "alice", Asset: "USD/2", Amount: big.NewInt(100)}, + }, res.Postings) +} diff --git a/internal/vm/program.go b/internal/vm/program.go new file mode 100644 index 00000000..632defc6 --- /dev/null +++ b/internal/vm/program.go @@ -0,0 +1,225 @@ +package vm + +import ( + "encoding/binary" + "fmt" + "math/big" +) + +type Program struct { + Instructions []Instruction + + StringsPool []string + IntsPool []big.Int + + MaxRegString byte + MaxRegInt byte + MaxRegPortion byte + MaxRegBool byte +} + +var le = binary.LittleEndian + +// TODO review AI blob +func (p Program) Encode() []byte { + instrs := make([]byte, 4*len(p.Instructions)) + for i, ins := range p.Instructions { + instrs[i*4], instrs[i*4+1], instrs[i*4+2], instrs[i*4+3] = ins.Opcode, ins.A, ins.B, ins.C + } + + strs := encodeStringsPool(p.StringsPool) + ints := encodeIntsPool(p.IntsPool) + maxRegs := encodeMaxRegs(p) + + buf := make([]byte, 0, formatHeaderLen+4*6+len(instrs)+len(strs)+len(ints)+len(maxRegs)) + buf = appendFormatHeader(buf, "NUMB", 4) + buf = appendSection(buf, SectionInstructions, instrs) + buf = appendSection(buf, SectionStringsPool, strs) + buf = appendSection(buf, SectionIntsPool, ints) + buf = appendSection(buf, SectionMaxRegisters, maxRegs) + return buf +} + +// These fields hold the per-bank register *count* (== highest index + 1), as +// emitted by the assembler. Real indices are 0..0xFE (0xFF is the nil sentinel), +// so the largest possible count is 255. When the max-registers section is absent +// we assume the bank uses every usable register, i.e. this default. +const maxRegDefault byte = 255 + +func encodeMaxRegs(p Program) []byte { + return []byte{p.MaxRegString, p.MaxRegInt, p.MaxRegPortion, p.MaxRegBool} +} + +// One byte per bank, positional, append-only order. The section length is the +// number of banks the writer knew. +// +// - absent (len 0): no info, so every bank defaults to maxRegDefault (safe). +// - present: bank i uses buf[i] when i < len; banks beyond len default to 0, +// since a bank the (older) writer didn't know is a type the program predates +// and provably uses none of. +// +// Extra trailing bytes (a newer writer) are ignored; a program that actually uses +// such a bank is rejected later via its unknown opcodes. +func parseMaxRegs(buf []byte) (str, i, portion, bool_ byte) { + if len(buf) == 0 { + return maxRegDefault, maxRegDefault, maxRegDefault, maxRegDefault + } + at := func(idx int) byte { + if idx < len(buf) { + return buf[idx] + } + return 0 + } + return at(0), at(1), at(2), at(3) +} + +func encodeStringsPool(strings []string) []byte { + buf := make([]byte, 4) + le.PutUint32(buf, uint32(len(strings))) + var lenBuf [4]byte + for _, s := range strings { + le.PutUint32(lenBuf[:], uint32(len(s))) + buf = append(buf, lenBuf[:]...) + buf = append(buf, s...) + } + return buf +} + +func encodeIntsPool(ints []big.Int) []byte { + buf := make([]byte, 4) + le.PutUint32(buf, uint32(len(ints))) + var lenBuf [4]byte + for i := range ints { + n := &ints[i] + sign := byte(0) + if n.Sign() < 0 { + sign = 1 + } + mag := n.Bytes() // absolute value, big-endian (big.Int's native form) + buf = append(buf, sign) + le.PutUint32(lenBuf[:], uint32(len(mag))) + buf = append(buf, lenBuf[:]...) + buf = append(buf, mag...) + } + return buf +} + +func parseInstructions(buf []byte) ([]Instruction, error) { + if len(buf)%4 != 0 { + return nil, fmt.Errorf("instructions section size %d not a multiple of 4", len(buf)) + } + instructions := make([]Instruction, len(buf)/4) + for i := range instructions { + off := i * 4 + instructions[i] = Instruction{ + buf[off], + buf[off+1], + buf[off+2], + buf[off+3], + } + } + return instructions, nil +} + +func parseStringsPool(buf []byte) ([]string, error) { + if len(buf) == 0 { + return nil, nil + } + if len(buf) < 4 { + return nil, fmt.Errorf("strings pool: count truncated") + } + n := le.Uint32(buf) + total := uint64(len(buf)) + if uint64(n)*4 > total-4 { // every record is at least a 4B length prefix + return nil, fmt.Errorf("strings pool: count %d exceeds buffer size %d", n, total) + } + idx := uint64(4) + out := make([]string, n) + for i := range out { + if idx+4 > total { + return nil, fmt.Errorf("string %d: length prefix out of bounds", i) + } + strLen := uint64(le.Uint32(buf[idx:])) + idx += 4 + end := idx + strLen // operands <= ~4.3e9, sum fits in uint64 + if end > total { + return nil, fmt.Errorf("string %d: body [%d:%d] out of bounds (%d)", i, idx, end, total) + } + out[i] = string(buf[idx:end]) // copies; Program no longer references buf + idx = end + } + return out, nil +} + +func parseIntsPool(buf []byte) ([]big.Int, error) { + if len(buf) == 0 { + return nil, nil + } + if len(buf) < 4 { + return nil, fmt.Errorf("ints pool: count truncated") + } + n := le.Uint32(buf) + total := uint64(len(buf)) + if uint64(n)*5 > total-4 { // every record is at least a 5B header + return nil, fmt.Errorf("ints pool: count %d exceeds buffer size %d", n, total) + } + idx := uint64(4) + out := make([]big.Int, n) + for i := range out { + if idx+5 > total { + return nil, fmt.Errorf("int %d: header out of bounds", i) + } + sign := buf[idx] + magLen := uint64(le.Uint32(buf[idx+1:])) + idx += 5 + end := idx + magLen + if end > total { + return nil, fmt.Errorf("int %d: magnitude [%d:%d] out of bounds (%d)", i, idx, end, total) + } + out[i].SetBytes(buf[idx:end]) // big-endian, unsigned magnitude + switch sign { + case 0: + // non-negative + case 1: + out[i].Neg(&out[i]) + default: + return nil, fmt.Errorf("int %d: invalid sign byte %d", i, sign) + } + idx = end + } + return out, nil +} + +func DecodeProgram(buf []byte) (Program, error) { + sections, err := decodeSections("NUMB", buf, SectionInstructions, SectionStringsPool, SectionIntsPool, SectionMaxRegisters) + if err != nil { + return Program{}, err + } + + instructions, err := parseInstructions(sections[SectionInstructions]) + if err != nil { + return Program{}, err + } + + stringsPool, err := parseStringsPool(sections[SectionStringsPool]) + if err != nil { + return Program{}, err + } + + intsPool, err := parseIntsPool(sections[SectionIntsPool]) + if err != nil { + return Program{}, err + } + + maxStr, maxInt, maxPortion, maxBool := parseMaxRegs(sections[SectionMaxRegisters]) + + return Program{ + Instructions: instructions, + StringsPool: stringsPool, + IntsPool: intsPool, + MaxRegString: maxStr, + MaxRegInt: maxInt, + MaxRegPortion: maxPortion, + MaxRegBool: maxBool, + }, nil +} diff --git a/internal/vm/program_encode_test.go b/internal/vm/program_encode_test.go new file mode 100644 index 00000000..2a006a73 --- /dev/null +++ b/internal/vm/program_encode_test.go @@ -0,0 +1,198 @@ +package vm + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProgramEncodeDecodeRoundTrip(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + abc(Op_LoadStr, 0, 1, 2), + bc(Op_LoadInt, 3, 1), + abc(Op_AddInt, 4, 3, 3), + }, + StringsPool: []string{"world", "dest", "USD/2"}, + IntsPool: []big.Int{*big.NewInt(0), *big.NewInt(-42)}, + } + got, err := DecodeProgram(prog.Encode()) + require.NoError(t, err) + require.Equal(t, prog.Instructions, got.Instructions) + require.Equal(t, prog.StringsPool, got.StringsPool) + for i := range prog.IntsPool { + require.Zero(t, got.IntsPool[i].Cmp(&prog.IntsPool[i])) + } +} + +func TestEmptyProgramRoundTrip(t *testing.T) { + _, err := DecodeProgram(Program{}.Encode()) + require.NoError(t, err) +} + +func TestDecodeSkipsUnknownSection(t *testing.T) { + prog := Program{ + Instructions: []Instruction{bc(Op_LoadInt, 0, 0)}, + IntsPool: []big.Int{*big.NewInt(7)}, + } + buf := prog.Encode() + // bump the section count and append an unknown (skippable) section + le.PutUint16(buf[6:], le.Uint16(buf[6:])+1) + buf = appendSection(buf, 0x0999, []byte("future")) + + got, err := DecodeProgram(buf) + require.NoError(t, err) + require.Equal(t, prog.Instructions, got.Instructions) +} + +func TestDecodeRejectsUnknownRequiredSection(t *testing.T) { + buf := Program{}.Encode() + le.PutUint16(buf[6:], le.Uint16(buf[6:])+1) + buf = appendSection(buf, mustUnderstandBit|0x0999, []byte("required")) + + _, err := DecodeProgram(buf) + require.Error(t, err) +} + +func TestDecodeRejectsNewerVersion(t *testing.T) { + buf := Program{}.Encode() + le.PutUint16(buf[4:], FormatVersion+1) + + _, err := DecodeProgram(buf) + require.Error(t, err) +} + +func TestDecodeRejectsTruncatedSection(t *testing.T) { + buf := Program{StringsPool: []string{"abc"}}.Encode() + _, err := DecodeProgram(buf[:len(buf)-2]) + require.Error(t, err) +} + +func TestDecodeRejectsDuplicateSection(t *testing.T) { + buf := Program{}.Encode() + le.PutUint16(buf[6:], le.Uint16(buf[6:])+1) + buf = appendSection(buf, SectionStringsPool, nil) + + _, err := DecodeProgram(buf) + require.Error(t, err) +} + +func TestRoundTripEdgeValues(t *testing.T) { + prog := Program{ + StringsPool: []string{"", "héllo", "x"}, + IntsPool: []big.Int{*big.NewInt(0), *new(big.Int).Lsh(big.NewInt(1), 300), *big.NewInt(-1)}, + } + got, err := DecodeProgram(prog.Encode()) + require.NoError(t, err) + require.Equal(t, prog.StringsPool, got.StringsPool) + for i := range prog.IntsPool { + require.Zero(t, got.IntsPool[i].Cmp(&prog.IntsPool[i])) + } +} + +func TestMaxRegRoundTrip(t *testing.T) { + prog := Program{MaxRegString: 3, MaxRegInt: 7, MaxRegPortion: 12, MaxRegBool: 5} + got, err := DecodeProgram(prog.Encode()) + require.NoError(t, err) + require.Equal(t, prog.MaxRegString, got.MaxRegString) + require.Equal(t, prog.MaxRegInt, got.MaxRegInt) + require.Equal(t, prog.MaxRegPortion, got.MaxRegPortion) + require.Equal(t, prog.MaxRegBool, got.MaxRegBool) +} + +func TestMaxRegDefaultsWhenAbsent(t *testing.T) { + var buf []byte + buf = appendFormatHeader(buf, "NUMB", 0) // no sections at all + got, err := DecodeProgram(buf) + require.NoError(t, err) + require.Equal(t, maxRegDefault, got.MaxRegString) + require.Equal(t, maxRegDefault, got.MaxRegInt) + require.Equal(t, maxRegDefault, got.MaxRegPortion) + require.Equal(t, maxRegDefault, got.MaxRegBool) +} + +func TestMaxRegShortSectionDefaultsTrailingToZero(t *testing.T) { + // writer knew only 2 banks: string=3, int=7 + var buf []byte + buf = appendFormatHeader(buf, "NUMB", 1) + buf = appendSection(buf, SectionMaxRegisters, []byte{3, 7}) + + got, err := DecodeProgram(buf) + require.NoError(t, err) + require.Equal(t, byte(3), got.MaxRegString) + require.Equal(t, byte(7), got.MaxRegInt) + require.Equal(t, byte(0), got.MaxRegPortion) // beyond the writer's banks -> 0 + require.Equal(t, byte(0), got.MaxRegBool) +} + +func TestMaxRegExtraTrailingBytesIgnored(t *testing.T) { + // writer knew a 5th bank; this reader ignores the extra bytes + var buf []byte + buf = appendFormatHeader(buf, "NUMB", 1) + buf = appendSection(buf, SectionMaxRegisters, []byte{1, 2, 3, 4, 99}) + + got, err := DecodeProgram(buf) + require.NoError(t, err) + require.Equal(t, byte(1), got.MaxRegString) + require.Equal(t, byte(2), got.MaxRegInt) + require.Equal(t, byte(3), got.MaxRegPortion) + require.Equal(t, byte(4), got.MaxRegBool) +} + +func TestDecodeMalformed(t *testing.T) { + u32 := func(v uint32) []byte { + b := make([]byte, 4) + le.PutUint32(b, v) + return b + } + oneSection := func(tag uint16, content []byte) []byte { + var b []byte + b = appendFormatHeader(b, "NUMB", 1) + return appendSection(b, tag, content) + } + badMagic := Program{}.Encode() + badMagic[0] = 'X' + + cases := map[string][]byte{ + "bad magic": badMagic, + "short buffer": {'N', 'U', 'M'}, + "instructions not mult 4": oneSection(SectionInstructions, []byte{1, 2, 3}), + "string count truncated": oneSection(SectionStringsPool, []byte{0, 0}), + "string count absurd": oneSection(SectionStringsPool, u32(0xFFFFFFFF)), + "string body oob": oneSection(SectionStringsPool, append(u32(1), u32(5)...)), + "int count absurd": oneSection(SectionIntsPool, u32(0xFFFFFFFF)), + "int magnitude oob": oneSection(SectionIntsPool, append(append(u32(1), 0), u32(5)...)), + "int invalid sign": oneSection(SectionIntsPool, append(append(u32(1), 2), u32(0)...)), + } + for name, buf := range cases { + t.Run(name, func(t *testing.T) { + _, err := DecodeProgram(buf) + require.Error(t, err) + }) + } +} + +func FuzzDecodeProgram(f *testing.F) { + f.Add(Program{}.Encode()) + f.Add(Program{ + Instructions: []Instruction{bc(Op_LoadInt, 0, 0)}, + StringsPool: []string{"x"}, + IntsPool: []big.Int{*big.NewInt(1)}, + }.Encode()) + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = DecodeProgram(data) // must not panic on arbitrary input + }) +} + +func TestDecodeMissingPoolIsEmpty(t *testing.T) { + // a program with only an instructions section + var buf []byte + buf = appendFormatHeader(buf, "NUMB", 1) + buf = appendSection(buf, SectionInstructions, []byte{byte(Op_LoadInt), 0, 0, 0}) + + got, err := DecodeProgram(buf) + require.NoError(t, err) + require.Empty(t, got.StringsPool) + require.Empty(t, got.IntsPool) +} diff --git a/internal/vm/section.go b/internal/vm/section.go new file mode 100644 index 00000000..5838a296 --- /dev/null +++ b/internal/vm/section.go @@ -0,0 +1,83 @@ +package vm + +import "fmt" + +// v2 dropped the monetary register bank: MK_MONETARY/GET_AMOUNT/GET_ASSET are +// gone, BALANCE/ASSERT_NON_NEGATIVE_BALANCE/MONETARY_TO_STRING changed operand +// banks, META_MONETARY grew an ext word, and SectionMaxRegisters lost its 4th byte. +const FormatVersion uint16 = 2 + +const ( + SectionInstructions uint16 = 0x01 // NUMB only + SectionStringsPool uint16 = 0x02 + SectionIntsPool uint16 = 0x03 + SectionMaxRegisters uint16 = 0x04 // NUMB only; optional, absent => every bank defaults to maxRegDefault +) + +// A section tag with this bit set must be understood by the decoder: an unknown +// such tag is a hard error rather than a skipped section. +const mustUnderstandBit uint16 = 0x8000 + +// magic(4) + version(2) + section count(2) +const formatHeaderLen = 4 + 2 + 2 + +func appendFormatHeader(buf []byte, magic string, sectionCount uint16) []byte { + buf = append(buf, magic...) + var h [4]byte + le.PutUint16(h[0:], FormatVersion) + le.PutUint16(h[2:], sectionCount) + return append(buf, h[:]...) +} + +func appendSection(buf []byte, tag uint16, content []byte) []byte { + var h [6]byte + le.PutUint16(h[0:], tag) + le.PutUint32(h[2:], uint32(len(content))) + buf = append(buf, h[:]...) + return append(buf, content...) +} + +// decodeSections validates the magic and version, then walks the section list +// into a tag -> content map. Missing sections are simply absent (callers treat +// them as empty). Unknown tags are skipped unless they carry mustUnderstandBit. +func decodeSections(magic string, buf []byte, knownTags ...uint16) (map[uint16][]byte, error) { + if len(buf) < formatHeaderLen || string(buf[0:4]) != magic { + return nil, fmt.Errorf("bad magic (expected %q)", magic) + } + version := le.Uint16(buf[4:]) + if version > FormatVersion { + return nil, fmt.Errorf("encoded by a newer numscript version (format v%d, supported up to v%d)", version, FormatVersion) + } + + known := make(map[uint16]bool, len(knownTags)) + for _, t := range knownTags { + known[t] = true + } + + count := le.Uint16(buf[6:]) + idx := formatHeaderLen + sections := make(map[uint16][]byte, count) + for i := range count { + if idx+6 > len(buf) { + return nil, fmt.Errorf("section %d: header truncated at offset %d", i, idx) + } + tag := le.Uint16(buf[idx:]) + length := le.Uint32(buf[idx+2:]) + idx += 6 + + end := uint64(idx) + uint64(length) + if end > uint64(len(buf)) { + return nil, fmt.Errorf("section %d (tag 0x%x): content [%d:%d] exceeds buffer %d", i, tag, idx, end, len(buf)) + } + + if !known[tag] && tag&mustUnderstandBit != 0 { + return nil, fmt.Errorf("unknown required section tag 0x%x", tag) + } + if _, dup := sections[tag]; dup { + return nil, fmt.Errorf("duplicate section tag 0x%x", tag) + } + sections[tag] = buf[idx:end] + idx = int(end) + } + return sections, nil +} diff --git a/internal/vm/vars.go b/internal/vm/vars.go new file mode 100644 index 00000000..d39298e9 --- /dev/null +++ b/internal/vm/vars.go @@ -0,0 +1,43 @@ +package vm + +import ( + "math/big" +) + +type Vars struct { + StringsPool []string + IntsPool []big.Int +} + +func DecodeVars(buf []byte) (Vars, error) { + sections, err := decodeSections("NVAR", buf, SectionStringsPool, SectionIntsPool) + if err != nil { + return Vars{}, err + } + + stringsPool, err := parseStringsPool(sections[SectionStringsPool]) + if err != nil { + return Vars{}, err + } + + intsPool, err := parseIntsPool(sections[SectionIntsPool]) + if err != nil { + return Vars{}, err + } + + return Vars{ + StringsPool: stringsPool, + IntsPool: intsPool, + }, nil +} + +func (v Vars) Encode() []byte { + strs := encodeStringsPool(v.StringsPool) + ints := encodeIntsPool(v.IntsPool) + + buf := make([]byte, 0, formatHeaderLen+2*6+len(strs)+len(ints)) + buf = appendFormatHeader(buf, "NVAR", 2) + buf = appendSection(buf, SectionStringsPool, strs) + buf = appendSection(buf, SectionIntsPool, ints) + return buf +} diff --git a/internal/vm/vars_test.go b/internal/vm/vars_test.go new file mode 100644 index 00000000..b7dd7c31 --- /dev/null +++ b/internal/vm/vars_test.go @@ -0,0 +1,111 @@ +package vm + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/stretchr/testify/require" +) + +func TestVarsRoundTrip(t *testing.T) { + in := Vars{ + StringsPool: []string{"alice", "USD/2"}, + IntsPool: []big.Int{*big.NewInt(1), *big.NewInt(4), *big.NewInt(-100)}, + } + + out, err := DecodeVars(in.Encode()) + require.NoError(t, err) + require.Equal(t, in, out) +} + +func TestVarsRoundTripEdgeValues(t *testing.T) { + in := Vars{ + StringsPool: []string{"", "héllo", "x"}, + IntsPool: []big.Int{*big.NewInt(0), *new(big.Int).Lsh(big.NewInt(1), 300), *big.NewInt(-1)}, + } + out, err := DecodeVars(in.Encode()) + require.NoError(t, err) + require.Equal(t, in.StringsPool, out.StringsPool) + for i := range in.IntsPool { + require.Zero(t, out.IntsPool[i].Cmp(&in.IntsPool[i])) + } +} + +func TestDecodeVarsMalformed(t *testing.T) { + u32 := func(v uint32) []byte { + b := make([]byte, 4) + le.PutUint32(b, v) + return b + } + oneSection := func(tag uint16, content []byte) []byte { + var b []byte + b = appendFormatHeader(b, "NVAR", 1) + return appendSection(b, tag, content) + } + badMagic := Vars{}.Encode() + badMagic[0] = 'X' + + newerVersion := Vars{}.Encode() + le.PutUint16(newerVersion[4:], FormatVersion+1) + + cases := map[string][]byte{ + "bad magic": badMagic, + "short buffer": {'N', 'V', 'A'}, + "newer version": newerVersion, + "string count truncated": oneSection(SectionStringsPool, []byte{0, 0}), + "string count absurd": oneSection(SectionStringsPool, u32(0xFFFFFFFF)), + "string body oob": oneSection(SectionStringsPool, append(u32(1), u32(5)...)), + "int count absurd": oneSection(SectionIntsPool, u32(0xFFFFFFFF)), + "int magnitude oob": oneSection(SectionIntsPool, append(append(u32(1), 0), u32(5)...)), + "int invalid sign": oneSection(SectionIntsPool, append(append(u32(1), 2), u32(0)...)), + } + for name, buf := range cases { + t.Run(name, func(t *testing.T) { + _, err := DecodeVars(buf) + require.Error(t, err) + }) + } +} + +func FuzzDecodeVars(f *testing.F) { + f.Add(Vars{}.Encode()) + f.Add(Vars{ + StringsPool: []string{"x"}, + IntsPool: []big.Int{*big.NewInt(1)}, + }.Encode()) + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = DecodeVars(data) // must not panic on arbitrary input + }) +} + +func TestLoadVarOpcodes(t *testing.T) { + vars, err := DecodeVars(Vars{ + StringsPool: []string{"world", "dest"}, + IntsPool: []big.Int{*big.NewInt(42)}, + }.Encode()) + require.NoError(t, err) + + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, sUSD, 0), // r_s0 = "USD/2" (current asset) + abc(Op_SetCurrentAsset, sUSD, 0, 0), + bc(Op_LoadVarStr, 1, 0), // r_s1 = var strings[0] = "world" + bc(Op_LoadVarStr, 2, 1), // r_s2 = var strings[1] = "dest" + bc(Op_LoadVarInt, 0, 0), // r_i0 = var ints[0] = 42 + abc(Op_PullAccount, 1, 1, 0), // r_i1 = pull(world, cap r_i0) + abc(0, nilReg, nilReg, 0), // ext: no overdraft, no color + abc(Op_SendToAccount, 2, nilReg, nilReg), // send to dest + }, + StringsPool: []string{"USD/2"}, + } + + res, execErr := Exec(context.Background(), NewVm(prog), &vars, mockStore{}) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(42)}, + } + require.Equal(t, want, res.Postings) +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go new file mode 100644 index 00000000..c3fb00f2 --- /dev/null +++ b/internal/vm/vm.go @@ -0,0 +1,512 @@ +package vm + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/formancehq/numscript/internal/runtime" +) + +const nilReg byte = 0xFF + +// The three ops a mark cannot survive, rejected by the arms below. save is on the +// list permanently, unlike the other two: it floors the balance at zero, and the +// clamp destroys the information needed to invert it. +var ( + errSendWhileMarkOpen = errors.New("send while a mark is open") + errSetAssetWhileMarkOpen = errors.New("set_current_asset while a mark is open") + errSaveWhileMarkOpen = errors.New("save while a mark is open") +) + +type Vm struct { + program Program + runstate *runtime.RunState + + // a monetary is not a bank of its own: it travels as a (str asset, int amount) + // register pair + stringsRegs [256]string // asset,string,account + intsRegs [256]big.Int + portionsRegs [256]big.Rat + boolsRegs [256]bool +} + +func NewVm( + program Program, +) *Vm { + return &Vm{ + program: program, + } +} + +type Store interface { + GetBalance( + ctx context.Context, + account string, + asset string, + color string, + ) (*big.Int, error) + + GetMetadata( + ctx context.Context, + account, + key string, + ) (string, bool, error) +} + +func lookupMeta(ctx context.Context, store Store, account, key string) (string, ExecutionError) { + v, ok, err := store.GetMetadata(ctx, account, key) + if err != nil { + return "", StoreError{Wrapped: err} + } + if !ok { + return "", MetadataNotFoundError{Account: account, Key: key} + } + return v, nil +} + +type runtimeStoreAdapter struct { + ctx context.Context + store Store +} + +func (s runtimeStoreAdapter) GetBalance( + account string, + asset string, + color string, +) (*big.Int, error) { + return s.store.GetBalance(s.ctx, account, asset, color) +} + +func Exec[S Store]( + ctx context.Context, + vm *Vm, + vars *Vars, + store S, // a generic S should allow monomorphisation of the Store +) (runtime.ExecutionResult, ExecutionError) { + runtimeStore := runtimeStoreAdapter{ + ctx: ctx, + store: store, + } + // RunState fetches balances lazily through this store; a fetch error surfaces + // from the RunState call that triggered it, wrapped in StoreError below. + if vm.runstate == nil { + vm.runstate = runtime.New(runtimeStore) + } else { + vm.runstate.Reset(runtimeStore) + } + runstate := vm.runstate + + var txMeta map[string]string + var accountsMeta runtime.AccountsMetadata + + // Hoist register banks and constant pools into locals so the hot loop indexes + // them directly instead of reloading the header off *vm / vm.program on every + // access. + intsRegs := &vm.intsRegs + stringsRegs := &vm.stringsRegs + portionsRegs := &vm.portionsRegs + boolsRegs := &vm.boolsRegs + intsPool := vm.program.IntsPool + stringsPool := vm.program.StringsPool + + instrs := vm.program.Instructions + instructionsLen := len(instrs) + + var currentAsset string + pc := 0 + + for pc < instructionsLen { + instr := instrs[pc] + pc++ + + switch Opcode(instr.Opcode) { + // --- Domain-specific ops + case Op_PullAccount: + // TODO crashes if this is the last instruction (the ext word is + // missing): instrs[pc] reads past the end. e.g. a program ending in a + // lone Op_PullAccount word. + instrExt := instrs[pc] + pc++ + + account := stringsRegs[instr.B] + + var cap *big.Int + if instr.C != nilReg { + cap = &intsRegs[instr.C] + } + + var overdraft *big.Int + if instrExt.A != nilReg { + overdraft = &intsRegs[instrExt.A] + } + + var color string + if instrExt.B != nilReg { + color = stringsRegs[instrExt.B] + } + + out := &intsRegs[instr.A] + switch { + case cap != nil: + if err := runstate.Pull(out, account, "", cap, overdraft, color); err != nil { + return runtime.ExecutionResult{}, StoreError{Wrapped: err} + } + case overdraft != nil: + if err := runstate.PullUncapped(out, account, "", overdraft, color); err != nil { + return runtime.ExecutionResult{}, StoreError{Wrapped: err} + } + default: + return runtime.ExecutionResult{}, InvalidUncappedSource{Account: account} + } + + case Op_SendToAccount: + // a send while a mark is open would consume the queue from the front and + // leave that mark pointing at the wrong boundary. Compiled numscript never + // emits it, since sources only pull. + if runstate.HasOpenMark() { + return runtime.ExecutionResult{}, InternalError{Err: errSendWhileMarkOpen} + } + + var dest *string + if instr.A != nilReg { + s := stringsRegs[instr.A] + dest = &s + } + + var cap *big.Int + if instr.B != nilReg { + cap = &intsRegs[instr.B] + } + + var color *string + if instr.C != nilReg { + color = &stringsRegs[instr.C] + } + + if cap == nil { + if err := runstate.SendUncapped(dest, "", color); err != nil { + return runtime.ExecutionResult{}, StoreError{Wrapped: err} + } + } else { + if err := runstate.Send(dest, "", cap, color); err != nil { + return runtime.ExecutionResult{}, StoreError{Wrapped: err} + } + } + + case Op_CheckEnoughFunds: + got := &intsRegs[instr.A] + needed := &intsRegs[instr.B] + if got.Cmp(needed) == -1 { + return runtime.ExecutionResult{}, MissingFundsError{ + Asset: currentAsset, + Got: got, + Needed: needed, + } + } + + case Op_Save: + // a save while a mark is open survives the rewind, which only repays queued + // sources and reverses postings; its floor at zero is not invertible at all. + if runstate.HasOpenMark() { + return runtime.ExecutionResult{}, InternalError{Err: errSaveWhileMarkOpen} + } + + account := stringsRegs[instr.A] + asset := stringsRegs[instr.B] + var amount *big.Int + if instr.C != nilReg { + amount = &intsRegs[instr.C] + } + if err := runstate.Save(account, "", asset, "", amount); err != nil { + return runtime.ExecutionResult{}, StoreError{Wrapped: err} + } + + // the mark ops take no register: the mark is a depth on a LIFO the run-state + // owns, so nothing can name a depth it never marked + case Op_MarkPush: + runstate.MarkPush() + + case Op_MarkEnd: + if err := runstate.MarkEnd(instr.A == 1); err != nil { + return runtime.ExecutionResult{}, InternalError{Err: err} + } + + case Op_AssertLeftover: + leftover := &portionsRegs[instr.A] + sign := leftover.Sign() + if sign < 0 || (instr.B == 1 && sign != 0) { + sum := new(big.Rat).Sub(big.NewRat(1, 1), leftover) + return runtime.ExecutionResult{}, InvalidAllotmentSum{ActualSum: *sum} + } + + case Op_SetCurrentAsset: + // a rewind repays queued funds into the current asset's balance, so + // changing the asset mid-region would repay the wrong one + if runstate.HasOpenMark() { + return runtime.ExecutionResult{}, InternalError{Err: errSetAssetWhileMarkOpen} + } + currentAsset = stringsRegs[instr.A] + runstate.SetCurrentAsset(currentAsset) + + case Op_AssertSameAsset: + left := stringsRegs[instr.A] + right := stringsRegs[instr.B] + if left != right { + return runtime.ExecutionResult{}, AssetMismatchError{ + Expected: left, + Got: right, + } + } + + case Op_AssertValidAccount: + account := stringsRegs[instr.A] + if !runtime.ValidateAccount(account) { + return runtime.ExecutionResult{}, InvalidAccountName{Name: account} + } + + case Op_AssertValidColor: + color := stringsRegs[instr.A] + if !runtime.ValidateColor(color) { + return runtime.ExecutionResult{}, InvalidColor{Color: color} + } + + case Op_AssertNonNegativeBalance: + amount := &intsRegs[instr.A] + if amount.Sign() < 0 { + return runtime.ExecutionResult{}, NegativeBalanceError{ + Account: stringsRegs[instr.B], + Amount: *amount, + } + } + + case Op_SetTxMeta: + if txMeta == nil { + txMeta = map[string]string{} + } + txMeta[stringsRegs[instr.A]] = stringsRegs[instr.B] + + case Op_SetAccountMeta: + if accountsMeta == nil { + accountsMeta = runtime.AccountsMetadata{} + } + account := stringsRegs[instr.A] + accMeta := accountsMeta[account] + if accMeta == nil { + accMeta = runtime.AccountMetadata{} + accountsMeta[account] = accMeta + } + accMeta[stringsRegs[instr.B]] = stringsRegs[instr.C] + + case Op_MetaStr: + v, err := lookupMeta(ctx, store, stringsRegs[instr.B], stringsRegs[instr.C]) + if err != nil { + return runtime.ExecutionResult{}, err + } + stringsRegs[instr.A] = v + + case Op_MetaInt: + account, key := stringsRegs[instr.B], stringsRegs[instr.C] + v, err := lookupMeta(ctx, store, account, key) + if err != nil { + return runtime.ExecutionResult{}, err + } + n, ok := runtime.ParseNumber(v) + if !ok { + return runtime.ExecutionResult{}, BadMetaValueError{Account: account, Key: key, Raw: v} + } + intsRegs[instr.A].Set(n) + + case Op_MetaPortion: + account, key := stringsRegs[instr.B], stringsRegs[instr.C] + v, err := lookupMeta(ctx, store, account, key) + if err != nil { + return runtime.ExecutionResult{}, err + } + r, perr := runtime.ParsePortion(v) + if perr != nil { + return runtime.ExecutionResult{}, BadMetaValueError{Account: account, Key: key, Raw: v} + } + portionsRegs[instr.A].Set(r) + + case Op_MetaMonetary: + // TODO crashes if this is the last instruction (the ext word carrying the + // amount destination is missing), same as Op_PullAccount. + instrExt := instrs[pc] + pc++ + + account, key := stringsRegs[instr.B], stringsRegs[instr.C] + v, err := lookupMeta(ctx, store, account, key) + if err != nil { + return runtime.ExecutionResult{}, err + } + asset, amount, merr := runtime.ParseMonetary(v) + if merr != nil { + return runtime.ExecutionResult{}, BadMetaValueError{Account: account, Key: key, Raw: v} + } + stringsRegs[instr.A] = asset + intsRegs[instrExt.A].Set(amount) + + // --- Vars + // TODO both crash if vars is nil (Exec called with no vars for a + // program that reads them), or if GetBC() >= len(vars pool) (caller + // passed fewer vars than the program declares). + case Op_LoadVarInt: + intsRegs[instr.A].Set(&vars.IntsPool[instr.GetBC()]) + + case Op_LoadVarStr: + stringsRegs[instr.A] = vars.StringsPool[instr.GetBC()] + + // --- Jumps + case Op_JmpIfFalse: + if !boolsRegs[instr.A] { + pc += int(instr.GetBC()) + } + + case Op_JmpIfTrue: + if boolsRegs[instr.A] { + pc += int(instr.GetBC()) + } + + case Op_Jmp: + pc += int(instr.GetBC()) + + // --- consts + // TODO both crash if GetBC() >= len(pool), e.g. an Op_LoadInt referring to + // pool index 5 in a program whose ints pool has 3 entries. + case Op_LoadInt: + const_ := &intsPool[instr.GetBC()] + intsRegs[instr.A].Set(const_) + + case Op_LoadStr: + const_ := stringsPool[instr.GetBC()] + stringsRegs[instr.A] = const_ + + case Op_ConstTrue: + boolsRegs[instr.A] = true + + case Op_ConstFalse: + boolsRegs[instr.A] = false + + // --- Binary ops + case Op_LtInt: + boolsRegs[instr.A] = intsRegs[instr.B].Cmp(&intsRegs[instr.C]) < 0 + + case Op_EqInt: + boolsRegs[instr.A] = intsRegs[instr.B].Cmp(&intsRegs[instr.C]) == 0 + + case Op_AddInt: + left := &intsRegs[instr.B] + right := &intsRegs[instr.C] + intsRegs[instr.A].Add(left, right) + + case Op_SubInt: + left := &intsRegs[instr.B] + right := &intsRegs[instr.C] + intsRegs[instr.A].Sub(left, right) + + case Op_AddString: + stringsRegs[instr.A] = stringsRegs[instr.B] + stringsRegs[instr.C] + + case Op_StrEq: + boolsRegs[instr.A] = stringsRegs[instr.B] == stringsRegs[instr.C] + + // portion comparison is *value* comparison: big.Rat normalises on + // construction, so 1/2 and 2/4 are the same rational and compare equal. + // Comparing numerator/denominator pairs separately would be wrong. + case Op_LtPortion: + boolsRegs[instr.A] = portionsRegs[instr.B].Cmp(&portionsRegs[instr.C]) < 0 + + case Op_EqPortion: + boolsRegs[instr.A] = portionsRegs[instr.B].Cmp(&portionsRegs[instr.C]) == 0 + + case Op_AddPortion: + left := &portionsRegs[instr.B] + right := &portionsRegs[instr.C] + portionsRegs[instr.A].Add(left, right) + + case Op_SubPortion: + left := &portionsRegs[instr.B] + right := &portionsRegs[instr.C] + portionsRegs[instr.A].Sub(left, right) + + case Op_MulPortion: + left := &portionsRegs[instr.B] + right := &portionsRegs[instr.C] + portionsRegs[instr.A].Mul(left, right) + + case Op_IntToPortion: + portionsRegs[instr.A].SetInt(&intsRegs[instr.B]) + + // floor: big.Rat's denominator is always positive, so Div (Euclidean) is + // the floor for negatives too + case Op_PortionToInt: + p := &portionsRegs[instr.B] + intsRegs[instr.A].Div(p.Num(), p.Denom()) + + case Op_MkPortion: + num := &intsRegs[instr.B] + den := &intsRegs[instr.C] + if den.Sign() == 0 { + return runtime.ExecutionResult{}, DivideByZeroError{Numerator: *num} + } + portionsRegs[instr.A].SetFrac(num, den) + + case Op_Balance: + account := stringsRegs[instr.B] + asset := stringsRegs[instr.C] + + bal, err := runstate.GetAccountBalance(account, "", asset, "") + if err != nil { + return runtime.ExecutionResult{}, StoreError{Wrapped: err} + } + // only the amount: the asset of the result is the asset operand, which + // the caller already holds in reg C + intsRegs[instr.A].Set(bal) + + // --- Unary ops + case Op_IntCopy: + arg := &intsRegs[instr.B] + intsRegs[instr.A].Set(arg) + + case Op_PortionCopy: + arg := &portionsRegs[instr.B] + portionsRegs[instr.A].Set(arg) + + case Op_StrCopy: + stringsRegs[instr.A] = stringsRegs[instr.B] + + case Op_BoolCopy: + boolsRegs[instr.A] = boolsRegs[instr.B] + + case Op_NegInt: + arg := &intsRegs[instr.B] + intsRegs[instr.A].Neg(arg) + + case Op_IntToString: + stringsRegs[instr.A] = intsRegs[instr.B].String() + + case Op_PortionToString: + stringsRegs[instr.A] = portionsRegs[instr.B].String() + + case Op_MonetaryToString: + stringsRegs[instr.A] = stringsRegs[instr.B] + " " + intsRegs[instr.C].String() + + case Op_IsZero: + boolsRegs[instr.A] = intsRegs[instr.B].Sign() == 0 + + case Op_Not: + boolsRegs[instr.A] = !boolsRegs[instr.B] + + default: + return runtime.ExecutionResult{}, InternalError{Err: fmt.Errorf("unknown opcode %d", instr.Opcode)} + } + } + + return runtime.ExecutionResult{ + Postings: runstate.GetPostings(), + Metadata: txMeta, + AccountsMetadata: accountsMeta, + }, nil +} diff --git a/internal/vm/vm_test.go b/internal/vm/vm_test.go new file mode 100644 index 00000000..36b6b2ac --- /dev/null +++ b/internal/vm/vm_test.go @@ -0,0 +1,526 @@ +package vm + +// White-box tests (package vm) that build a Program from struct literals, so +// they can reach encodings the compiler doesn't emit. Behavioural VM tests are +// written in the IR textual format instead — see ir_test.go. + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/stretchr/testify/require" +) + +// --- register allocation: one $rN namespace -> typed banks ---------------- +// +// $r0 "USD/2" -> strings[0] (sUSD) $r6 remaining -> ints[3] (iRem) +// $r1 10 -> ints[0] (iTen) $r7 "s1" -> strings[2] (sS1) +// $r3 asset -> strings[1] (sAsset) $r8 pulled1 -> ints[4] (iPulled1) +// $r4 amount -> ints[1] (iAmount) $r9 "s2" -> strings[3] (sS2) +// $r5 sum=0 -> ints[2] (iSum) $r10 pulled2 -> ints[5] (iPulled2) +// $r11 "dest" -> strings[4] (sDest) +// (added) zero overdraft bound -> ints[6] (iZero) -- gives BoundedZero +const ( + sUSD, sAsset, sS1, sS2, sDest = 0, 1, 2, 3, 4 + iTen, iAmount, iSum, iRem, iPulled1 = 0, 1, 2, 3, 4 + iPulled2, iZero = 5, 6 +) + +func abc(op Opcode, a, b, c byte) Instruction { + return Instruction{Opcode: byte(op), A: a, B: b, C: c} +} + +func bc(op Opcode, a byte, v uint16) Instruction { + return Instruction{Opcode: byte(op), A: a, B: byte(v), C: byte(v >> 8)} +} + +// --- mock store ----------------------------------------------------------- + +type mockStore struct { + bal map[runtime.PairKey]int64 + meta map[string]map[string]string +} + +func (m mockStore) GetBalance(ctx context.Context, account, asset string, color string) (*big.Int, error) { + return big.NewInt(m.bal[runtime.PairKey{Account: account, Asset: asset}]), nil +} + +func (m mockStore) GetMetadata(ctx context.Context, account, key string) (string, bool, error) { + v, ok := m.meta[account][key] + return v, ok, nil +} + +var _ Store = (*mockStore)(nil) + +// --- the test ------------------------------------------------------------- + +func assertValidAccountProgram(name string) Program { + return Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), + abc(Op_AssertValidAccount, 0, nilReg, nilReg), + }, + StringsPool: []string{name}, + } +} + +func balanceNonNegativeProgram() Program { + return Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), + bc(Op_LoadStr, 1, 1), + abc(Op_Balance, 0, 0, 1), + abc(Op_AssertNonNegativeBalance, 0, 0, nilReg), + }, + StringsPool: []string{"acc", "USD/2"}, + } +} + +func TestAssertNonNegativeBalance(t *testing.T) { + store := mockStore{bal: map[runtime.PairKey]int64{{Account: "acc", Asset: "USD/2"}: 50}} + if _, err := Exec(context.Background(), NewVm(balanceNonNegativeProgram()), nil, store); err != nil { + t.Fatalf("non-negative balance rejected: %v", err) + } + + store = mockStore{bal: map[runtime.PairKey]int64{{Account: "acc", Asset: "USD/2"}: -50}} + _, err := Exec(context.Background(), NewVm(balanceNonNegativeProgram()), nil, store) + if _, ok := err.(NegativeBalanceError); !ok { + t.Fatalf("expected NegativeBalanceError, got %v", err) + } +} + +func TestUnknownOpcode(t *testing.T) { + prog := Program{Instructions: []Instruction{abc(0xFE, 0, 0, 0)}} + _, err := Exec(context.Background(), NewVm(prog), nil, mockStore{}) + if _, ok := err.(InternalError); !ok { + t.Fatalf("expected InternalError, got %v", err) + } +} + +func TestMkPortionDivideByZero(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), + bc(Op_LoadInt, 1, 1), + abc(Op_MkPortion, 0, 0, 1), + }, + IntsPool: []big.Int{*big.NewInt(1), *big.NewInt(0)}, + } + _, err := Exec(context.Background(), NewVm(prog), nil, mockStore{}) + if _, ok := err.(DivideByZeroError); !ok { + t.Fatalf("expected DivideByZeroError, got %v", err) + } +} + +func TestAssertValidAccount(t *testing.T) { + _, err := Exec(context.Background(), NewVm(assertValidAccountProgram("users:001:wallet")), nil, mockStore{}) + if err != nil { + t.Fatalf("valid account rejected: %v", err) + } + + _, err = Exec(context.Background(), NewVm(assertValidAccountProgram("bad name!")), nil, mockStore{}) + if _, ok := err.(InvalidAccountName); !ok { + t.Fatalf("expected InvalidAccountName, got %v", err) + } +} + +// Nothing reads a bool yet, so the bank itself is the only observable effect. +func TestConstBool(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + abc(Op_ConstTrue, 0, nilReg, nilReg), + abc(Op_ConstFalse, 1, nilReg, nilReg), + // a register written twice keeps the last value + abc(Op_ConstTrue, 2, nilReg, nilReg), + abc(Op_ConstFalse, 2, nilReg, nilReg), + }, + } + + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + + require.True(t, vm.boolsRegs[0]) + require.False(t, vm.boolsRegs[1]) + require.False(t, vm.boolsRegs[2]) + require.False(t, vm.boolsRegs[3], "untouched registers stay false") +} + +// is_zero is the only projection from a quantity to a condition, so it has to +// agree with what Op_JmpIfZero used to test: sign, not magnitude. +func TestIsZero(t *testing.T) { + testCases := []struct { + name string + value int64 + want bool + }{ + {"zero", 0, true}, + {"positive", 7, false}, + {"negative", -7, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), + abc(Op_IsZero, 0, 0, nilReg), + }, + IntsPool: []big.Int{*big.NewInt(tc.value)}, + } + + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Equal(t, tc.want, vm.boolsRegs[0]) + }) + } +} + +// Portion addition and subtraction, over unequal denominators so the result has +// to be a real rational sum rather than a numerator-wise one. +func TestPortionArithmetic(t *testing.T) { + testCases := []struct { + name string + op Opcode + numL, denL int64 + numR, denR int64 + wantNum, wantDen int64 + }{ + {"add with equal denominators", Op_AddPortion, 1, 4, 1, 4, 1, 2}, + {"add with unequal denominators", Op_AddPortion, 1, 6, 1, 3, 1, 2}, + {"add to a whole", Op_AddPortion, 1, 3, 2, 3, 1, 1}, + {"add past a whole", Op_AddPortion, 3, 4, 1, 2, 5, 4}, + {"sub with unequal denominators", Op_SubPortion, 1, 2, 1, 6, 1, 3}, + {"sub to zero", Op_SubPortion, 1, 3, 1, 3, 0, 1}, + {"sub below zero", Op_SubPortion, 1, 4, 1, 2, -1, 4}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), bc(Op_LoadInt, 1, 1), + bc(Op_LoadInt, 2, 2), bc(Op_LoadInt, 3, 3), + abc(Op_MkPortion, 0, 0, 1), + abc(Op_MkPortion, 1, 2, 3), + abc(tc.op, 2, 0, 1), + }, + IntsPool: []big.Int{ + *big.NewInt(tc.numL), *big.NewInt(tc.denL), + *big.NewInt(tc.numR), *big.NewInt(tc.denR), + }, + } + + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + want := big.NewRat(tc.wantNum, tc.wantDen) + require.Zero(t, vm.portionsRegs[2].Cmp(want), + "got %s, want %s", vm.portionsRegs[2].RatString(), want.RatString()) + }) + } +} + +// One copy per bank. Each case writes a distinct value into reg 1, copies reg 1 +// into reg 0, and checks reg 0 took it — so a copy wired to the wrong bank, or a +// no-op, fails. +func TestBankCopies(t *testing.T) { + t.Run("int", func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 1, 0), + abc(Op_IntCopy, 0, 1, nilReg), + }, + IntsPool: []big.Int{*big.NewInt(-42)}, + } + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Zero(t, vm.intsRegs[0].Cmp(big.NewInt(-42))) + }) + + t.Run("portion", func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), + bc(Op_LoadInt, 1, 1), + abc(Op_MkPortion, 1, 0, 1), + abc(Op_PortionCopy, 0, 1, nilReg), + }, + IntsPool: []big.Int{*big.NewInt(1), *big.NewInt(3)}, + } + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Zero(t, vm.portionsRegs[0].Cmp(big.NewRat(1, 3))) + }) + + t.Run("str", func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 1, 0), + abc(Op_StrCopy, 0, 1, nilReg), + }, + StringsPool: []string{"USD/2"}, + } + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Equal(t, "USD/2", vm.stringsRegs[0]) + }) + + t.Run("bool", func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + abc(Op_ConstTrue, 1, nilReg, nilReg), + abc(Op_BoolCopy, 0, 1, nilReg), + // and the false direction, over a register that already held true + abc(Op_ConstTrue, 2, nilReg, nilReg), + abc(Op_ConstFalse, 3, nilReg, nilReg), + abc(Op_BoolCopy, 2, 3, nilReg), + }, + } + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.True(t, vm.boolsRegs[0]) + require.False(t, vm.boolsRegs[2], "copying false over true") + }) +} + +// A copy is a copy, not an alias: overwriting the source must not disturb the +// destination. Only the int and portion banks can get this wrong, since those two +// hold big values that are Set() into place rather than assigned. +func TestCopiesAreNotAliases(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 1, 0), // $1 = 7 + abc(Op_IntCopy, 0, 1, nilReg), // $0 = copy $1 + bc(Op_LoadInt, 1, 1), // $1 = 9 + }, + IntsPool: []big.Int{*big.NewInt(7), *big.NewInt(9)}, + } + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Zero(t, vm.intsRegs[0].Cmp(big.NewInt(7)), "the copy tracked its source") + require.Zero(t, vm.intsRegs[1].Cmp(big.NewInt(9))) +} + +// The two int comparisons, over both signs. Each case also asserts the negation, +// so `!=` — which has no opcode — is covered wherever `==` is. +func TestIntComparisons(t *testing.T) { + testCases := []struct { + name string + op Opcode + left, right int64 + want bool + }{ + {"lt when less", Op_LtInt, 3, 7, true}, + {"lt when equal", Op_LtInt, 7, 7, false}, + {"lt when greater", Op_LtInt, 7, 3, false}, + {"lt across zero", Op_LtInt, -7, 3, true}, + {"lt on negatives", Op_LtInt, -7, -3, true}, + + {"eq when equal", Op_EqInt, 7, 7, true}, + {"eq when different", Op_EqInt, 7, 3, false}, + {"eq on negatives", Op_EqInt, -7, -7, true}, + {"eq distinguishes sign", Op_EqInt, -7, 7, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), + bc(Op_LoadInt, 1, 1), + abc(tc.op, 0, 0, 1), + abc(Op_Not, 1, 0, nilReg), + }, + IntsPool: []big.Int{*big.NewInt(tc.left), *big.NewInt(tc.right)}, + } + + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Equal(t, tc.want, vm.boolsRegs[0]) + require.Equal(t, !tc.want, vm.boolsRegs[1], "not") + }) + } +} + +// The four derived operators have no opcodes: the front end normalises them onto +// Lt / Eq / Not. This checks each lowering against the operator it stands for, +// which is the property that makes leaving them out safe. +func TestDerivedComparisonLowerings(t *testing.T) { + // each lowering as it would be emitted, over a grid that covers <, == and > + values := []int64{-7, -1, 0, 1, 7} + + testCases := []struct { + name string + emit []Instruction // leaves the answer in bool reg 0 + want func(l, r int64) bool + }{ + { + name: "a > b -> Lt(b, a)", + emit: []Instruction{abc(Op_LtInt, 0, 1, 0)}, + want: func(l, r int64) bool { return l > r }, + }, + { + name: "a <= b -> Not(Lt(b, a))", + emit: []Instruction{abc(Op_LtInt, 1, 1, 0), abc(Op_Not, 0, 1, nilReg)}, + want: func(l, r int64) bool { return l <= r }, + }, + { + name: "a >= b -> Not(Lt(a, b))", + emit: []Instruction{abc(Op_LtInt, 1, 0, 1), abc(Op_Not, 0, 1, nilReg)}, + want: func(l, r int64) bool { return l >= r }, + }, + { + name: "a != b -> Not(Eq(a, b))", + emit: []Instruction{abc(Op_EqInt, 1, 0, 1), abc(Op_Not, 0, 1, nilReg)}, + want: func(l, r int64) bool { return l != r }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, l := range values { + for _, r := range values { + instrs := []Instruction{bc(Op_LoadInt, 0, 0), bc(Op_LoadInt, 1, 1)} + prog := Program{ + Instructions: append(instrs, tc.emit...), + IntsPool: []big.Int{*big.NewInt(l), *big.NewInt(r)}, + } + + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Equal(t, tc.want(l, r), vm.boolsRegs[0], "l=%d r=%d", l, r) + } + } + }) + } +} + +// Portion comparison is value comparison: big.Rat normalises on construction, so +// equal rationals with different spellings must compare equal. +func TestPortionComparisons(t *testing.T) { + // builds two portions from (numL/denL, numR/denR) and compares them + run := func(t *testing.T, op Opcode, numL, denL, numR, denR int64) bool { + t.Helper() + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), bc(Op_LoadInt, 1, 1), + bc(Op_LoadInt, 2, 2), bc(Op_LoadInt, 3, 3), + abc(Op_MkPortion, 0, 0, 1), // portion 0 = numL/denL + abc(Op_MkPortion, 1, 2, 3), // portion 1 = numR/denR + abc(op, 0, 0, 1), + }, + IntsPool: []big.Int{ + *big.NewInt(numL), *big.NewInt(denL), + *big.NewInt(numR), *big.NewInt(denR), + }, + } + vm := NewVm(prog) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + return vm.boolsRegs[0] + } + + t.Run("equality is by value, not by numerator/denominator", func(t *testing.T) { + require.True(t, run(t, Op_EqPortion, 1, 2, 2, 4), "1/2 == 2/4") + require.True(t, run(t, Op_EqPortion, 3, 9, 1, 3), "3/9 == 1/3") + require.False(t, run(t, Op_EqPortion, 1, 2, 1, 3), "1/2 != 1/3") + }) + + t.Run("ordering", func(t *testing.T) { + require.True(t, run(t, Op_LtPortion, 1, 3, 1, 2), "1/3 < 1/2") + require.False(t, run(t, Op_LtPortion, 1, 2, 1, 3), "1/2 not < 1/3") + require.False(t, run(t, Op_LtPortion, 1, 2, 2, 4), "equal values are not <") + }) +} + +// The two conditional jumps are duals: each takes the edge the other doesn't. +func TestConditionalJumps(t *testing.T) { + // jump over a CONST_TRUE writing bool reg 1, so reg 1 reports whether the + // jump was taken + prog := func(jmp Opcode, cond Opcode) Program { + return Program{ + Instructions: []Instruction{ + abc(cond, 0, nilReg, nilReg), + bc(jmp, 0, 1), + abc(Op_ConstTrue, 1, nilReg, nilReg), + }, + } + } + + testCases := []struct { + name string + jmp Opcode + cond Opcode + taken bool + }{ + {"jmp_if_false on false", Op_JmpIfFalse, Op_ConstFalse, true}, + {"jmp_if_false on true", Op_JmpIfFalse, Op_ConstTrue, false}, + {"jmp_if_true on true", Op_JmpIfTrue, Op_ConstTrue, true}, + {"jmp_if_true on false", Op_JmpIfTrue, Op_ConstFalse, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + vm := NewVm(prog(tc.jmp, tc.cond)) + _, err := Exec(context.Background(), vm, nil, mockStore{}) + require.Nil(t, err) + require.Equal(t, tc.taken, !vm.boolsRegs[1], "jump taken") + }) + } +} + +func TestExecutionErrorMessages(t *testing.T) { + testCases := []struct { + name string + err ExecutionError + msg string + }{ + {"MissingFundsError", MissingFundsError{Asset: "USD/2", Needed: big.NewInt(10), Got: big.NewInt(4)}, + "missing funds for asset USD/2: needed 10, got 4"}, + {"AssetMismatchError", AssetMismatchError{Expected: "USD/2", Got: "EUR/2"}, + "asset mismatch: expected USD/2, got EUR/2"}, + {"InvalidUncappedSource", InvalidUncappedSource{Account: "src"}, + "unbounded source is not allowed here: @src"}, + {"InvalidAllotmentSum", InvalidAllotmentSum{ActualSum: *big.NewRat(3, 2)}, + "invalid allotment: portions must sum to 1, got 3/2"}, + {"MetadataNotFoundError", MetadataNotFoundError{Account: "acc", Key: "k"}, + `metadata not found: acc["k"]`}, + {"BadMetaValueError", BadMetaValueError{Account: "acc", Key: "k", Raw: "oops"}, + `invalid metadata value for acc["k"]: "oops"`}, + {"InvalidAccountName", InvalidAccountName{Name: "not an account"}, + `invalid account name: "not an account"`}, + {"InvalidColor", InvalidColor{Color: "red"}, + `invalid color name: "red"`}, + {"NegativeBalanceError", NegativeBalanceError{Account: "src", Amount: *big.NewInt(-1)}, + "cannot fetch negative balance from account @src"}, + {"DivideByZeroError", DivideByZeroError{Numerator: *big.NewInt(7)}, + "cannot divide by zero (in 7/0)"}, + {"InternalError", InternalError{Err: errors.New("boom")}, "internal error: boom"}, + {"StoreError", StoreError{Wrapped: errors.New("store is down")}, "store error: store is down"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.msg, tc.err.Error()) + }) + } +} + +// The two wrapping errors must stay unwrappable, so hosts can inspect the cause. +func TestExecutionErrorsUnwrap(t *testing.T) { + cause := errors.New("cause") + require.ErrorIs(t, InternalError{Err: cause}, cause) + require.ErrorIs(t, StoreError{Wrapped: cause}, cause) +} diff --git a/ir-textual-format.md b/ir-textual-format.md new file mode 100644 index 00000000..97c48962 --- /dev/null +++ b/ir-textual-format.md @@ -0,0 +1,347 @@ +# The IR textual format + +The compiler doesn't emit `vm.Instruction` directly: it emits a `[]ir.Instr` stream first (see [compiler-architecture.md](compiler-architecture.md)). This document specifies the **textual notation** for that stream — the thing you get when you dump a compiled program, and the thing you can write by hand to feed the assembler. + +It is a real format, not just a pretty-printing convention: it has a grammar, a parser, and a round-trip guarantee. + +The whole IR layer lives in [internal/ir/](internal/ir/), and that package is the entire API: + +| what | where | +| --- | --- | +| grammar | [IR.g4](IR.g4) (ANTLR; generated into `internal/ir/internal/syntax/antlrParser/` by `just generate`) | +| text → `[]ir.Instr` | `ir.Parse` in [internal/ir/parse.go](internal/ir/parse.go) | +| `[]ir.Instr` → text | `ir.Dump` in [internal/ir/dump.go](internal/ir/dump.go) | +| `[]ir.Instr` → `vm.Program` | `ir.Assemble` in [internal/ir/assemble.go](internal/ir/assemble.go) | +| register typing | `ir.Typecheck` in [internal/ir/typecheck.go](internal/ir/typecheck.go) | +| round-trip tests | `TestRoundtripAllInstructions` in [internal/ir/parse_test.go](internal/ir/parse_test.go) | + +`ir.Parse` is the only way in: the grammar's AST lives under `internal/ir/internal/syntax`, which Go's import rules make unreachable from anywhere outside `internal/ir`. Callers see instructions, never parse trees. + +**Round-trip property:** for every instruction, `ir.Dump` of what `ir.Parse` returns is the text it was given. This is what makes the format usable for snapshot tests and for hand-writing IR fixtures. See [Round-trip caveats](#round-trip-caveats) for the (few) inputs that don't survive it. + +## Lexical structure + +``` +REG '$' [a-zA-Z_] [a-zA-Z0-9_]* $r0, $r12, $pulled +LABEL '#' [a-zA-Z_] [a-zA-Z0-9_]* #inorder_end_0 +INT [0-9]+ 42 (no sign, no separators) +STRING '"' ('\"' | ~["\r\n])* '"' "USD/2", "a\"b" +IDENTIFIER [a-z] [a-z0-9_]* mk_portion, account +TYPE_KEYWORD 'int' | 'str' | 'portion' | 'monetary' +BOOL 'true' | 'false' +``` + +* Spaces, tabs and newlines are **skipped**, not significant. Statements are delimited by the grammar, not by line breaks — `$r0 = 1 $r1 = 2` is two valid instructions. Newlines are pure convention (a very useful one). +* **There are no comments.** Any `//` or `#`-style comment is a syntax error (`#foo` lexes as a label). +* `BOOL` is likewise matched before `IDENTIFIER`, so `true` and `false` are reserved too. +* `TYPE_KEYWORD` is matched before `IDENTIFIER`, so `int`, `str`, `portion` and `monetary` are reserved: they cannot be used as an instruction name or an argument label. Register names are unaffected (`$int` is fine, the `$` starts a `REG`). `monetary` is vestigial — no instruction takes it as a type parameter any more (see below) — but it stays reserved until the grammar is regenerated. +* Instruction names and argument labels are lowercase-only (`IDENTIFIER` starts with `[a-z]`). + +## Statements + +A program is a flat sequence of two kinds of line: **label markers** and **instructions**. + +``` +#some_label ← label marker, flush left + set_current_asset($r3) ← instruction, indented 2 spaces +``` + +Indentation is cosmetic, but `ir.Dump` always emits labels at column 0 and instructions indented by two spaces. + +Instructions come in five shapes: + +``` + dest = name(args) instruction call with a destination + name(args) instruction call with no destination + dest = constant load + dest = $l + $r infix int arithmetic (only + and -) + $d += $r compound assign (only += and -=) +``` + +### Destinations + +``` +$r0 single register +[$r0, $r1] register list (meta_monetary only) +_ discard +``` + +`_` discards the result, and exists **only in the text**: there is no discard at the `ir.Instr` level. `ir.Parse` desugars each occurrence to a fresh register — allocated from the same counter as named ones, but bound to no name, so nothing can refer to it and each `_` gets its own (two discards that aliased would be forced to share a type). The write is still a write: the assembler gives that register a slot in its bank, so a discard costs a register even though nothing reads it. + +Because the desugaring happens on the way in, `_` doesn't survive a dump: `_ = int_copy($r0)` comes back as `$r1 = int_copy($r0)`. + +### Arguments + +Arguments are comma-separated and either **positional** or **labeled**: + +``` + check_enough_funds($r7, $r4) positional + $r8 = pull_account(account: $r5, cap: $r4) labeled +``` + +Which form an argument takes is fixed per instruction (see the reference below) — it is not a free choice. An argument value is one of: + +``` +$r0 register +#my_label label reference (the jumps only) +42 int literal (load_var index only) +``` + +A register list is a destination form only — no instruction takes one as an argument. + +Labeled arguments are looked up **by name**, so their order is free: `pull_account(cap: $c, account: $a)` is the same instruction as `pull_account(account: $a, cap: $c)`. `ir.Dump` always emits them in the canonical order given below. + +### Registers + +Registers in the IR are "logical": an unbounded stream of unsigned indices (`ir.Reg` is a `uint`), later mapped onto the VM's 256-per-bank physical registers by the assembler's allocator. Each register has exactly one type for its whole lifetime (`int`, `str`, `portion` or `bool`), checked by `ir.Typecheck` — the type is never written in the text, it is inferred from the instruction that writes the register. + +There is no monetary register. A monetary is a **pair** of registers, a `str` asset and an `int` amount, which the instructions below take and return separately. Nothing constructs or projects one, so `[USD/2 10]` is just the two registers holding `"USD/2"` and `10`. + +A register name is just a name: `ir.Parse` keeps a symbol table and allocates registers in order of **first appearance**, reusing the same one every later time a name shows up. `$r` is a convention, not an index — `$r7` is no more meaningful than `$src`. + +``` +$src = "acc" dumps back as $r0 = "acc" +$pulled = pull_account(account: $src) $r1 = pull_account(account: $r0) +``` + +This is why a dump round-trips: `ir.Dump` numbers registers `$r0`, `$r1`, … in the order they first appear, so re-parsing binds each name to the register it already had. Names of your own choosing are fine to write, they just come back as `$r` in first-appearance order. + +The compiler holds up its end by allocating registers in the order it emits them — `getCompiledOutput` in the compiler tests asserts the round-trip on every snapshot, so a change that breaks the ordering fails there. + +## Instruction reference + +Types are the register types of each operand; `?` marks an optional labeled argument. + +### Constants and variables + +| syntax | types | +| --- | --- | +| `$d = 42` | `int` | +| `$d = "USD/2"` | `str` | +| `$d = true` | `bool` | +| `$d = false` | `bool` | +| `$d = load_var(0)` | `int`; the index is a literal in `0..65535` | +| `$d = load_var(1)` | `str` | + +`true` and `false` are constants like the other two, but they need no pool entry: the value is in the opcode (`CONST_TRUE` / `CONST_FALSE`). They are only ever the right-hand side of a const assignment — no instruction takes a bool *operand*, so `set_current_asset(true)` doesn't parse. There is no `load_var` either: numscript has no bool of its own, so a bool register can only come from an instruction inside the program. + +`load_var` reads from the encoded `vm.Vars` pool at that index. There is no `load_var` / `load_var`: composite vars are encoded as their int/str components. A monetary var is two `load_var`s — `load_var` for the asset, `load_var` for the amount — and that pair *is* the value. + +### Pure arithmetic and constructors + +| syntax | signature | +| --- | --- | +| `$d = add_int($l, $r)` | `(int, int) -> int` | +| `$d = sub_int($l, $r)` | `(int, int) -> int` | +| `$d = add_string($l, $r)` | `(str, str) -> str` | +| `$d = add_portion($l, $r)` | `(portion, portion) -> portion` | +| `$d = sub_portion($l, $r)` | `(portion, portion) -> portion` | +| `$d = mul_portion($l, $r)` | `(portion, portion) -> portion` | +| `$d = mk_portion($num, $den)` | `(int, int) -> portion` | +| `$d = monetary_to_string($asset, $amt)` | `(str, int) -> str` — the `"ASSET AMOUNT"` form | + +`add_int` and `sub_int` have infix sugar, which is what `ir.Dump` always prints: + +``` + $r2 = $r0 + $r1 add_int + $r2 = $r0 - $r1 sub_int + $r0 += $r1 add_int where dest == left + $r0 -= $r1 sub_int where dest == left +``` + +So `add_int($a, $b)` parses fine, but a dump never contains it. No other operator has infix syntax. + +### Unary ops + +| syntax | signature | +| --- | --- | +| `$d = int_copy($a)` | `int -> int` | +| `$d = portion_copy($a)` | `portion -> portion` | +| `$d = str_copy($a)` | `str -> str` | +| `$d = bool_copy($a)` | `bool -> bool` | +| `$d = neg_int($a)` | `int -> int` | +| `$d = int_to_string($a)` | `int -> str` | +| `$d = portion_to_string($a)` | `portion -> str` | +| `$d = int_to_portion($a)` | `int -> portion` — exact | +| `$d = portion_to_int($a)` | `portion -> int` — **floors** | + +`int_to_portion` and `portion_to_int` are the only numeric crossings between the int and portion banks. `portion_to_int` truncates towards negative infinity (a `big.Rat` denominator is always positive, so this is `Div`, not a rounding), which is what makes an allotment share exact. + +There is no register-to-register move: `$r0 = $r1` is not valid syntax. Use the copy for the bank instead — there is exactly one per bank, and none crosses banks. A monetary has no copy of its own, since it is a `(str, int)` pair: copy the two halves. + +There is no `get_asset` / `get_amount` either: projecting a monetary means naming one of its two registers, which costs no instruction. `monetary_to_string` is listed above with the other constructors, since it takes the pair. + +### Comparisons and `not` + +Every instruction that produces a `bool`, other than the `true`/`false` constants: + +| syntax | signature | +| --- | --- | +| `$d = lt_int($l, $r)` | `(int, int) -> bool` — strict | +| `$d = eq_int($l, $r)` | `(int, int) -> bool` | +| `$d = str_eq($l, $r)` | `(str, str) -> bool` | +| `$d = is_zero($a)` | `int -> bool` — tests the *sign*, so a negative amount is not zero | +| `$d = lt_portion($l, $r)` | `(portion, portion) -> bool` — strict | +| `$d = eq_portion($l, $r)` | `(portion, portion) -> bool` | +| `$d = not($a)` | `bool -> bool` | + +Only `<` and `==` exist per type. The other four operators are **front-end normalisations**, so the IR never sees them and there is no `gt_*`, `lte_*`, `gte_*` or `neq_*`: + +``` +a < b -> lt_int($a, $b) +a > b -> lt_int($b, $a) operands swapped +a <= b -> $t = lt_int($b, $a) ; not($t) +a >= b -> $t = lt_int($a, $b) ; not($t) +a == b -> eq_int($a, $b) +a != b -> $t = eq_int($a, $b) ; not($t) +``` + +12 surface operators over 5 instructions. The reason is that every extra predicate is another case in the SMT encoder and in any formal model of the VM, so its cost is paid three times over; LLVM canonicalises the same way. `is_zero` is kept next to `eq_int` because it needs no materialised zero and sits on every quantity branch. + +`eq_portion` is **value** equality: `1/2 == 2/4` is true, since a portion register holds a normalised rational. + +`str` gets equality only, never ordering. Bool equality and structural comparison of tuples/arrays would also be front-end expansions rather than instructions. + +### Run-state reads (impure) + +| syntax | signature | +| --- | --- | +| `$d = balance($account, $asset)` | `(str, str) -> int` — the amount only; the monetary's asset is the `$asset` operand you already hold | +| `$d = meta($account, $key)` | `(str, str) -> str` | +| `$d = meta($account, $key)` | `(str, str) -> int` | +| `$d = meta($account, $key)` | `(str, str) -> portion` | +| `[$asset, $amt] = meta_monetary($account, $key)` | `(str, str) -> (str, int)` | + +`meta_monetary` is not `meta`: one store read yields both halves, so it is the only instruction that writes a **dest list**, and its list must be exactly two registers (asset then amount). + +### Funds movement + +``` + $pulled = pull_account(account: $a, cap: $c, overdraft: $o, color: $col) +``` +`account: str` is required; `cap: int`, `overdraft: int`, `color: str` are optional. Writes the amount actually pulled (`int`) into the destination. No `cap` means uncapped. Canonical dump order: `account, cap, overdraft, color`. + +``` + send_to_account(account: $a, cap: $c) +``` +No destination. Both arguments are optional: no `cap` sends everything currently queued; **no `account` refunds the funds to their sources without emitting postings**. + +``` + save(account: $a, asset: $as, amount: $amt) +``` +No destination. `account: str` and `asset: str` are required, `amount: int` is optional — omitting it saves the whole balance. + +There is no allotment instruction. Splitting an amount across portions is built out of the pure ops above: each share is `portion_to_int(mul_portion($p_i, int_to_portion($amount)))`, and the leftover from flooring is then handed to the earliest shares a unit at a time, using `lt_int` and forward jumps to a shared exit. See `compileAllotmentSplit` in `internal/compiler/compiler.go`. + +### Assertions and checks + +| syntax | operands | +| --- | --- | +| `check_enough_funds($got, $needed)` | `int, int` | +| `assert_leftover($portion)` | `portion` — leftover must be `>= 0` | +| `assert_leftover_exact($portion)` | `portion` — leftover must be exactly `0` | +| `assert_same_asset($l, $r)` | `str, str` | +| `assert_valid_account($a)` | `str` | +| `assert_valid_color($c)` | `str` | +| `assert_non_negative_balance($amt, $account)` | `int, str` — the account is only for the error | +| `set_current_asset($asset)` | `str` — required before `pull_account` / `send_to_account` | + +`assert_leftover` / `assert_leftover_exact` are two separate instruction names, not one instruction with an `exact:` flag. + +### Metadata writes + +| syntax | operands | +| --- | --- | +| `set_tx_meta($key, $value)` | `str, str` | +| `set_account_meta($account, $key, $value)` | `str, str, str` | + +### Control flow + +``` + jmp_if_false($cond, #my_label) + jmp_if_true($cond, #my_label) + jmp(#my_label) +#my_label +``` + +`$cond` is `bool`, so a quantity can't be a condition — that is the point of the bool bank, and `ir.Typecheck` rejects `jmp_if_false($some_amount, ..)` where it used to accept it. The two conditional forms are duals, so either edge of a condition is one instruction and there is no negation op. A bool comes from `true`/`false`, from `str_eq`, or from `is_zero` — the last being how a quantity reaches a branch: + +``` + $exhausted = is_zero($remaining_cap) + jmp_if_true($exhausted, #end) +``` + +For all three the target must be a label that is defined in the program, unique, and **after** the jump. The VM only permits forward jumps — that's what guarantees termination — and `ir.Parse` enforces all three rules, so a program that assembles can't loop: + +``` +jmp_if_false($r0, #nope) → label #nope is not defined in the program +#back → label #back is behind the jump (jumps must go forward) + jmp_if_false($r0, #back) +``` + +Together they express an if/else, which is how `@world`'s unboundedness is compiled (see `compiler-architecture.md`): + +``` + $eq = str_eq($account, $world) + jmp_if_false($eq, #not_world) + ; then arm + jmp(#end) +#not_world + ; else arm +#end +``` + +`labelMarker` is a pseudo-instruction: it emits no bytecode, it only feeds the assembler's symbol table. + +### Backtracking (`oneof`) + +``` + $mark = snapshot() // int: marks the current position of the source queue + restore($mark) // rewinds the source queue to a mark +``` + +`snapshot` takes no arguments and writes an `int` mark; `restore` reads one back. A `oneof` source compiles to a `snapshot` before the first branch and a `restore` before each retry. + +## A full example + +`send [USD/2 10] (source = @src destination = @dest)` compiles to: + +``` + $r0 = "USD/2" + $r1 = 10 + set_current_asset($r0) + $r2 = "src" + $r3 = 0 + $r4 = pull_account(account: $r2, cap: $r1, overdraft: $r3) + check_enough_funds($r4, $r1) + $r5 = "dest" + send_to_account(account: $r5) +``` + +`$r0` and `$r1` *are* the monetary: `set_current_asset` reads the asset half and the cap is the amount half, with nothing in between. + +## Round-trip caveats + +Known asymmetries between what `ir.Dump` writes and what the parser accepts: + +* **Register names don't survive.** `$src` comes back as `$r`, numbered by first appearance (see [Registers](#registers)). +* **`_` doesn't survive.** It's desugared to a fresh register on the way in, so it dumps as that register (see [Destinations](#destinations)). +* **Negative int literals are not expressible.** `INT` has no sign, so `$r0 = -1` is a syntax error, while `ir.Dump` would happily print it for a negative `ir.LoadInt`. This is not reachable today — the compiler emits `neg_int` for negative literals rather than a negative constant — but a constant-folding peephole could produce a dump that no longer parses. + +## Error handling + +Text → `[]ir.Instr` never panics: it reports `ir.Error`s. Anything the grammar rejects comes back as a syntax error (and since ANTLR's error recovery leaves partial nodes behind, no AST is built at all in that case). On top of that, `ir.Parse` reports what the grammar can't express: + +* unknown instruction names, and a type parameter on an instruction that doesn't take one +* wrong argument kinds or counts, unknown or duplicate labeled arguments +* duplicate labels, and jumps that don't resolve or don't go forward +* **a register that is read but never written**, reported under the name the text used: + +``` + $a = 42 + $y = lt_int($a, $b) → 3:3: register $b is read but never written +``` + +Since jumps only go forward, text order is execution order, so a read with no earlier write can't be reached by any path — it would hand the VM whatever that register happens to hold. Note this is a linear check: a register written only inside a branch that may be skipped and read afterwards is *not* caught here, which is the job of the path-sensitive bytecode verifier. + +Type errors are **not** checked by `ir.Parse`: writing a `str` register where an `int` is expected parses happily and is caught by `ir.Typecheck` afterwards. diff --git a/numscript.go b/numscript.go index eb4dc8d7..eca913ee 100644 --- a/numscript.go +++ b/numscript.go @@ -3,8 +3,10 @@ package numscript import ( "context" + "github.com/formancehq/numscript/internal/compiler" "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/vm" ) // This struct represents a parsed numscript source code @@ -125,3 +127,55 @@ func (p ParseResult) ResolveDependencies(ctx context.Context, vars VariablesMap, func (p ParseResult) GetSource() string { return p.parseResult.Source } + +type ( + VarsEncoder = compiler.VarsEncoder + CompiledProgram = vm.Program + VMStore = vm.Store + Vm = vm.Vm + Vars = vm.Vars +) + +var NewVm = vm.NewVm + +var DecodeVars = vm.DecodeVars + +func (p ParseResult) Compile() (VarsEncoder, CompiledProgram, error) { + return p.CompileWithFeatureFlags(nil) +} + +// CompileWithFeatureFlags compiles the program, rejecting any construct gated +// behind an experimental feature flag that isn't in featureFlags. +func (p ParseResult) CompileWithFeatureFlags(featureFlags map[string]struct{}) (VarsEncoder, CompiledProgram, error) { + if len(p.parseResult.Errors) != 0 { + return VarsEncoder{}, CompiledProgram{}, p.parseResult.Errors[0] + } + + if featureFlags == nil { + featureFlags = make(map[string]struct{}) + } + + return compiler.Compile(p.parseResult.Value, featureFlags) +} + +func Compile(source string) (VarsEncoder, CompiledProgram, error) { + return Parse(source).Compile() +} + +func CompileWithFeatureFlags(source string, featureFlags map[string]struct{}) (VarsEncoder, CompiledProgram, error) { + return Parse(source).CompileWithFeatureFlags(featureFlags) +} + +var DecodeCompiledProgram = vm.DecodeProgram + +func ExecVm[S VMStore](ctx context.Context, machine *Vm, vars *Vars, store S) (ExecutionResult, error) { + res, execErr := vm.Exec(ctx, machine, vars, store) + if execErr != nil { + return ExecutionResult{}, execErr + } + + // Postings share one type now (runtime.Posting); the VM leaves scope fields + // empty. TODO map VM tx/account metadata (stringified) onto the typed + // contract; deferred together with scopes in the VM. + return ExecutionResult{Postings: res.Postings}, nil +} diff --git a/numscript_test.go b/numscript_test.go index fa24e76a..18d48dc9 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -454,12 +454,19 @@ set_tx_meta( }) require.Nil(t, err) + // @alice starts with 20 and receives 100, so its running balance is 120. + // (@bob is an unbounded source, so its balance is never fetched; @alice's + // starting balance is fetched lazily by the mid-script balance() call.) require.Equal(t, interpreter.Metadata{ - "k": interpreter.NewMonetary("USD/2", 100), + "k": interpreter.Monetary{Asset: "USD/2", Amount: interpreter.NewMonetaryInt(120)}, }, res.Metadata) require.Equal(t, - []numscript.BalanceQuery(nil), + []numscript.BalanceQuery{ + { + {Account: "alice", Asset: "USD/2"}, + }, + }, store.GetBalancesCalls, )