Skip to content

Tolk v1.5: break/continue in loops, smarter optimization, colorful errors, and preparation for v2.0 - #2577

Merged
tolya-yanot merged 22 commits into
masterfrom
tolk-v1.5
Oct 3, 2026
Merged

tolya-yanot merged 22 commits into
masterfrom
tolk-v1.5

Conversation

@tolk-vm

@tolk-vm tolk-vm commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

This release brings several language/compiler features that were too complex to implement earlier, so they are only coming now. Also, there is also a huge internal refactoring in preparation for Tolk v2.0.

Brief list of what's included:

  1. @inline functions with multiple returns
  2. break/continue in loops, finally for TVM
  3. Type aliases as directional subtypes
  4. Reorganized purity concept and dead-code removal
  5. Colorful compilation errors with multiple locations and hints
  6. Several bytecode optimizations
  7. Preparing the kernel for Tolk v2.0

Inline with multiple returns

TLDR:

  • functions with multiple returns are now inlined if control flow is straightforward
  • for example, if (cond) { return false } ...
  • not supported: return from loops, from try/catch, or in unexpected positions
  • @inline annotation either inlines or fires an error

In detail:

Before, "return in the middle" prevented inlining

Since Tolk v1.0, the compiler supported auto-inlining simple functions.

There was also an @inline annotation which told the compiler: "if possible, inline it; otherwise, defer inlining to Fift with INLINECALLDICT".

What could prevent a function from being inlined?

  • it's recursive
  • it's used as a reference, e.g. var callback = someFn
  • it has early/multiple returns

The last point is crucial. Only a single return, the last statement, was allowed:

// this was NOT inlined earlier
fun checkPositive(x: int) {
    if (x <= 0) {
        return;    // because of this
    }
    ...
}

In practice, early returns are quite common, especially for filtering out some conditions at start.

Since Tolk v1.5, the example above is inlined.

Why returns in the middle are hard

Even C++ compilers used to struggle with inlining functions with multiple return statements efficiently.

Inlining means "paste a function body into a caller". A return inside that body can not emit RET, since RET would exit the caller. Instead, return is "assign to OUT IR vars".

fun calcSquare(x: int): int {
    var sq = x * x;
    return sq;
}

When inlining, its body looks like

// pseudocode, treat OUT as a magic parameter
fun calcSquare(x: int, OUT: int): void {
    var sq = x * x;
    OUT = sq;
}

What's the problem with multiple returns? Assigning OUT is easy. The hard part is: everything below must not execute, and there is no goto in TVM to skip it.

For example, take this function:

fun sign(x: int) {
    if (x < 0) { return -1 }
    if (x == 0) { return 0 }
    return 1
}

If we just replace return with OUT, it will be INCORRECT:

fun sign(x: int, OUT: int): void {
    if (x < 0) { OUT = -1 }   // incorrect, because after assignment
    if (x == 0) { OUT = 0 }   // this also executes
    OUT = 1                   // and this
}

Then how can we achieve a correct result without RET and GOTO? The answer: rewrite function body with if/else:

fun sign(x: int, OUT: int): void {
    if (x < 0) { OUT = -1 }
    else {
        if (x == 0) { OUT = 0 }
        else { OUT = 1 }
    }
}

That's exactly what the new solution does internally. The rewrite doesn't happen at the AST level, though. It happens during AST-to-IR loweing.

Examples of rewrites

Example 1:

fun demo(x: int) {
    if (x < 0) { return 0 }
    return 2;
}

gets translated to

fun demo(x: int) {
    if (x < 0) { OUT = 0 }
    else { OUT = 2 }
}

Example 2:

fun demo() {
    if (cond1) { return }
    if (cond2) { xxx; return }
    else { yyy }
    zzz
}

gets translated to

fun demo() {
    if (cond1) { }
    else { if (cond2) { xxx }
           else { yyy; zzz } }
}

Example 3:

fun demo() {
    match (subj) {
        0 => return 0,
        1 => throw 1,
    }
    if (cond) { return 2 }
    zzz
    return 3;
}

gets translated to

fun demo() {
    match (subj) {
        0 => { OUT = 0 },
        1 => { throw 1 },
        else => {
          if (cond) { OUT = 2 }
          else { zzz;
                 OUT = 3; }
} } }

What is supported exactly

Supported:

  • if/else where both branches terminate (or neither branch contains a return)
  • if/else where one branch terminates and the other does not: tail goes to the other
  • match where all arms terminate (or neither branch contains a return);
    if it's not exhaustive, tail goes to its implicit else
  • match where all but one arms terminate, and it's exhaustive: tail goes to non-terminating

NOT supported:

  • unbalanced if/else, e.g. if (cond) { if (cond) { return 1 } }
  • unbalanced match, e.g. 2 arms terminate, 3 do not
  • return in other positions: in a loop, in try/catch
  • return hidden inside an expression, e.g. if (match(subj) { 0 => return 0 })

In one sentence:
Inlinable <=> every if/match that contain return leaves at most one fallthrough path — a single place where all the code below gets relocated.

In practice, you want if (cond) { return } in the beginning. Now all such functions are inlined as expected.

@inline annotation fires if impossible

Before, if the compiler could not inline a function marked @inline, it silently deferred inlining to Fift with INLINECALLDICT. That kind of inlining is much less effective because its body is compiled in isolation, without cross-boundary stack optimizations or constant folding. That's how FunC's inline attribute works, by the way.

Now, since practical cases with early returns are covered, the compiler fires an error if @inline is used but cannot be applied.

@inline
fun factorial(x: int): int {
    if (x <= 1) { return 1; }
    return x * factorial(x - 1);
}

now gets an error

error: function `factorial` can't be inlined
    --> /path/to/file.tolk:2:5
     | 
   2 | fun factorial(x: int): int {
     |     ^^^^^^^^^
hint: `@inline` is impossible, because a function recursively calls itself

A reason is printed for all unsupported kinds: "because of return inside a loop" and so on.

So, @inline either gets applied or fires an error; it is never silently skipped if impossible.

Auto-inline rules didn't change

The compiler auto-inlines a function if it can be inlined and either

  • is relatively small
  • or is called exactly once

Just "can be inlined" was extended to support multiple return points, nothing else changed.

Implementation details

See inline-return-analysis.cpp. It answers a single question: "can a function be inlined in place, and if yes, where does the control flow go after every return in the middle?"

Its outcome:

  • either InlineReturnPlan: "here are the if/match nodes that route the flow"
  • or "no, because ..."

See pipe-detect-inline-in-place.cpp. For each function, it detects whether it should be inlined in-place or not. For instance, it uses heuristics such as "is the function small enough" and builds the call graph to detect recursion.

During lowering (pipe-ast-to-legacy.cpp) the same InlineReturnPlan is used. During lowering if/else, the tail is collected and inserted into a routing branch.

Example:

fun demo() {
    if (...) { return }
    else {
        if (...) { return }
        yyy     // tail of if 2
    }
    zzz  // tail of if 1
}

Here yyy and zzz are rerouted into implicit else of the inner if — not at their direct positions:

    if (...) { return }
    else {
        if (...) { return }
        else {
            yyy
            zzz
        }
    }

See struct FallthroughTail, TailResult, and emit_tail_statements. The logic is quite involved because it has to deal with debug marks, scoping, and implicit returning mutate params in the end.


Support break/continue in loops

TLDR:

  • break in while, do-while, repeat
  • continue with some limitations
  • not supported in try-catch
  • not supported: break and return in the same loop (they both are RETALT in loop's body)

In detail:

break is supported

// calc bits until zero byte
while (true) {
    val ch = bytes.loadUint(8);
    if (ch == 0) {
        break;
    }
    bits += 8;
}

break is allowed anywhere except try-catch: in deep if, in lazy match, etc.

With nested loops, break from the inner works correctly also.

while (cond1) {
    repeat (arr.size()) {
        // ...
        break;   // from `repeat` to next
    }
    next;
}

How break works

TVM already has *BRK counterparts for loops: WHILE → WHILEBRK, AGAIN → AGAINBRK, etc. These instructions save c1 continuation. As a result, RETALT inside a *BRK loop actually breaks it, jumping after the loop.

So, when a loop contains direct break, Tolk generates a *BRK instruction. A break statement is essentially RETALT.

Of course, things are not so simple, because the compiler needs to deal with unexpected stack layout change. Control flow for smart casts and fixpoint iteration in loops also required careful addressing. But anyway, break is quite simple — surprisingly, much simpler than continue.

Limitation: break + return not supported

When both are used in a loop, it's an error

loop {
    if (cond1) {
        break;
    }
    if (cond2) {
        return;
    }
}

Unsupported, because both are RETALT at the TVM level (working differently for XXX and XXXBRKin TVM).

continue is supported

// handle non-blocked payment claims
var r = claims.findFirst();
while (r.isFound) {
    val owner = r.getKey();
    val amount = r.loadValue();
    r = claims.iterateNext(r);

    if (amount <= 0) {
        continue;
    }
    if (blocked.exists(owner)) {
        continue;
    }
    paid += amount;
    sendPayment(owner, amount);
}

continue is allowed almost everywhere except try-catch. There are some limitations, not visible in practice.

continue jumps to condition (exactly as in other languages). If the condition is false, the next iteration will not start.

With nested loops, continue from the inner works correctly also.

while (cond1) {
    repeat (arr.size()) {
        front;
        // ...
        continue;   // to front if not last iteration
    }
}

How continue works

Interesting: break is MUCH simpler than continue, because TVM already has *BRK instructions that work organically with RETALT.

Note that we can NOT use RET for continue: it will not work inside IFs and other continuations.

Then how?

The answer is surprising: continue in loops is THE SAME as an early return in inline functions! Look:

loop {
    if (x < 0) { continue }
    next
}

gets translated to

loop {
    if (x < 0) { }
    else { next }
}

Example 2:

loop {
    if (cond1) { continue }
    if (cond2) { xxx; continue }
    else { yyy }
    zzz
}

gets translated to

loop {
    if (cond1) { }
    else { if (cond2) { xxx }
           else { yyy; zzz } }
}

Example 3:

loop {
    match (subj) {
        0 => continue,
        1 => throw 1,
    }
    if (cond) { continue }
    zzz
}

gets translated to

loop {
    match (subj) {
        0 => { },
        1 => { throw 1 },
        else => {
          if (cond) { }
          else { zzz }
} } }

It's exactly the same as return in the middle! The files inline-return-analysis.cpp and loop-control-analysis.cpp are almost identical, producing InlineReturnPlan and LoopContinuePlan, respectively. They are combined during AST-to-IR lowering via the "rest tail".

So, there is no special TVM instruction. Instead, the code is rewritten with if-else branches.

Limitations for continue

That's why limitations for continue are identical to return when inlining.

Unsupported example:

loop {
    match (num) {
        1 => continue,
        2 => continue,
        3 => xxx,
        4 => yyy,
        5 => zzz,
    }
}

This can not be rewritten with if-else, because 2 branches terminate, 3 do not.

In one sentence:
Continuable <=> every if/match that contain continue leaves at most one fallthrough path — a single place where all the code below gets relocated.

In practice, you want if (cond) { continue } at the top level, it works perfectly.


User-written code is always preserved

TLDR:

  • all code written by the user is always preserved; it cannot be silently optimized out as unused
  • @pure annotation remains only for asm functions

In detail:

Previously, @pure calls could be removed

Tolk compiler still has a FunC kernel inside. FunC's behavior relies on the impure keyword. Once forgotten, a call to that function could be optimized out. That was a common mistake when using FunC:

parse_std_addr(smth);   // FunC removed it: it's not impure
// result: address was not validated

To avoid this common mistake, Tolk inverted the annotations. impure became the default. To make an unused result removable, you had to mark the function @pure.

@pure is widely used in stdlib; it was uncommon in user-space contracts. That's why Tolk did not remove calls to user-defined functions — but it still removed calls to built-in ones.

fun demo() {
    var s = createEmptySlice();
    s.loadInt(32);   // did not throw, because it was removed: loadInt is `@pure`
}

Even when assigned, it was removed if the variable remained unused.

var num = s.loadInt(32);  // call also removed if `num` not used

While these examples can seem obvious, the following is less obvious:

fun checkMessage(map: map<int32, SomeStruct>) {
    val r = map.get(123);
    if (r.isFound) {
        // we do not need the result, just want to validate it's encoded correctly
        r.loadValue();
        // will the compiler remove `loadValue` call? it's `@pure` and unused
    }
}

What does @pure mean, and what purposes does it serve?

The concept of "purity" is very vague. For example, __throw(123) and debug.print(123) are not removed, because those functions are not pure — they change TVM state.

But in general, purity, effectfulness, and nothrow were mixed under one annotation. This raises a bunch of questions that cannot be answered cleanly. For example, read this discussion.

Why did the compiler remove them?

Removing dead code (pruning unreachable calculations) is a standard compiler optimization.

When you use SomeStruct.fromSlice or createMessage, the compiler generates an internal representation full of stores/loads, mathematical operators (also pure functions), and so on. Later, unused calculations are removed, always-false branches are stripped off, etc.

Why is it a problem?

In straightforward code, you usually don't notice this nuance. You call functions and use their results. If you don't need a result, you simply don't call the function. You don't intend to leave dead code in contract logic.

There are two ways you can run into dead-code removal:

  1. Corner cases like r.loadValue() above
  2. You are an LLM or a bug bounty hunter :)

Really! At least half of the bug reports I get are related to unexpected code removal.

Examples:

  • create an empty slice, call s.loadBool() — it's removed, no exception thrown
  • call SomeStruct.fromSlice(s, {assertEnd:false}) without using the result — also removed as unused
  • write var num = x << 2 and don't use num — calculation removed, no exception on x > MAX_INT/2
  • write -x, unused negation also removed:
-x;

Removed, because -x is internally an __unary_minus(x) function which is @pure. That's why for x = -MAX_INT it didn't trigger an overflow: this calculation was not present in bytecode at all.

Do such cases appear in real code? No. Do they exist? Yes, they do.

Of course, I close all such bug reports as "not a bug", because this is indeed the behavior implemented in FunC's optimizer, and it was indeed expected from my point of view.

So the problem was: the compiler mixed "user-written" calls and "compiler-inserted" ones, effectively pruning dead code in both cases.

Tolk v1.5 separates user code from compiler-generated

Starting from this version, all code written by the user is always preserved; it cannot be silently optimized out as unused. Dead-code pruning exists, but only for compiler-generated IR.

This makes behavior more predictable. Every reachable expression you write will be preserved and evaluated. Hopefully, this won't increase gas usage in real contracts, because they don't contain leftover unused calls. And in corner cases like loadValue() you no longer have to guess whether an unused call will be dropped.

The @pure annotation is now applicable to asm functions only. It means:

  • it's safe to delete if inserted by the compiler itself
  • it's safe for the compiler to reorder pure function calls

I hope this change will reduce the number of bug reports from LLMs: about 95% of them are false positives and simply take my time to analyze.

@pure is still not enough in corner cases, though

The compiler reorders calculations when it's safe, to produce fewer stack shuffles. Example:

struct Point {
    x: int
    y: int
}

fun construct() {
    // note: not {x,y} but {y,x}
    var p = Point {
        y: calcY(),
        x: calcX(),
    };
    return p;
}

construct returns a Point — integers (x,y), in that order. But the user wrote literal fields in reverse order (y,x), intentionally or not.

The compiler has two choices:

// either
... calcY
... calcX
SWAP

// or reorder in advance, cheaper
... calcX
... calcY

If calcX and calcY are asm functions, the compiler assumes it is safe to reorder them when both are @pure. Strictly speaking, that is not correct. If both can throw, reordering may change which exception is observed at runtime.

@pure and a (theoretical) @nothrow (or throws-list) are completely different. For speculative reordering, the compiler would need to know all side effects of every function and assembler instruction. The current FunC kernel cannot provide this information.

This also cannot be disabled, because reordering is used not only for shuffled object literals, but — much more importantly — to match asm functions with stack return order (asm -> 2 1 0). The standard library has plenty of such functions, to mirror TVM signatures.

So this slightly incorrect behavior still exists and will not change, at least until Tolk v2.0 which will have a from-scratch compiler backend.


Type aliases become directional subtypes

TLDR:

  • type MyInt = int creates a subtype of int — a directional relation:
    • having fun int.method, you can call both 5.method() and my5.method()
    • having fun MyInt.method, you can call my5.method() but NOT 5.method()
  • this resolves corner cases in method overloading and custom serializers
  • aliases can now own methods that do not leak to the underlying type

In detail:

Earlier, a type alias was interchangeable with its underlying type

The type syntax creates a type alias. It worked the same way as in TypeScript: everywhere you use int, you can use MyInt, and vice versa.

For example, a merkle-proof cell is just a cell, you create an alias to emphasize it:

type MerkleProofCell = cell

struct ExtProof {
    data: bits512
    proof: MerkleProofCell
}

The proof field works exactly like a cell. Everything still works if it's declared as cell. You introduce MerkleProofCell for readability only.

Then you want MerkleProofCell to have a special method:

fun MerkleProofCell.validateIsSpecial(self)

The problem is: this method could also be called on any cell, because MerkleProofCell is fully interchangeable with cell in all directions.

var c: cell = createEmptyCell();

// worked until Tolk v1.5
// doesn't work now: an alias owns its methods
c.validateIsSpecial()

The problem with custom serializers

From Tolk v1.0, any type alias could overload serialization methods:

fun MerkleProofCell.packToBuilder(self, mutate b: builder) {
    // ...
}

fun MerkleProofCell.unpackFromSlice(mutate s: slice) {
    // ...
}

After the v1.0 release, many people asked me to add pack/unpack overloads for structs also. Because otherwise, they had to create type PointA = Point to overload PointA.packToBuilder.

It was a hack, because once you overload PointA.packToBuilder, it actually changed the behavior of a struct. Why? The same reason as cell.validateIsSpecial resolves for any cell. Similarly, for myPoint.toCell() a method PointA.packToBuilder was applicable.

I allowed overloading packToBuilder for structs directly to avoid confusion, but this actually made things even worse. What if someone creates a struct, an alias to it, and overloads packToBuilder for both of them? What if that struct/alias is generic? And so on.

Does anyone actually need to overload packToBuilder for both a struct, an alias referencing that struct, then use match over an alias, narrowing it to identical struct, and face strange behavior? Of course not.

But who does this anyway? Of course — LLMs and bug bounty hunters :)

I received a set of bug reports, absolutely impractical, that exploit the inconsistency between regular methods and packToBuilder specifically.

After analysing all of them, I decided to modify the behavior of type aliases.

Type aliases are now directional subtypes

When resolving object.method(), IntAlias->int is allowed, but int->IntAlias is not.

This allows type aliases to declare methods not applicable from original types. In fact, this behavior is better than before.

Example 1:

type MerkleProofCell = cell

fun MerkleProofCell.validateIsSpecial(self) { ... }

fun demo(c: cell) {
    // does not work
    c.validateIsSpecial();   // compilation error

    // works
    (c as MerkleProofCell).validateIsSpecial();

    // of course, regular methods for `cell` work for both:
    c.hash();
    merkleC.hash();
}

Example 2:

struct Point { ... }

type PointAlias = Point

fun PointAlias.packToBuilder(self, mutate b: builder) { ... }

fun demo(p: Point) {
    // uses auto serialization
    p.toCell();

    // uses a manual one
    (p as PointAlias).toCell();
}

Example 3:

// currently, only one incoming message is implemented
// in the future, Msg2 may appear
struct Msg1 { ... }

// for the future, to extend `Msg1 | Msg2`
// but currently it's an alias for one struct!
type AllowedMessages = Msg1

fun demo() {
    var msg = lazy AllowedMessages.fromSlice(...);
    match (msg) {
        Msg1 => {
            // earlier this was a problem, because
            // `AllowedMessages` is an alias for ONE struct `Msg1`
            // they were interchangeable, 
            // causing ambiguity in case of overloaded methods
        }
    }
}

The last example is also quite important, because once Msg2 is implemented, behavior could have changed, since AllowedMessages would become an alias for a union, not for one struct.

Now, all potential flaws are prevented by the type system.

In practice this is also more intuitive: methods defined for an alias stay only on that alias.

Note that subtype relation is also structural:

// available for `array<cell>` and `array<MerkleProofCell>`
fun array<cell>.method1(self)

// available for `array<MerkleProofCell>` only
fun array<MerkleProofCell>.method2(self)

By the way, subtypes correctly handle this case:

type AssetsDict = dict
type BalanceDict = dict

fun AssetsDict.validate(self) { ... }
fun BalanceDict.validate(self) { ... }

fun demo(a: AssetsDict, b: BalanceDict) {
    // ok
    a.validate();
    b.validate();

    // btw, this is an error
    plainDict.validate();   // no such method for type `dict`
}

Everything works as expected: no method confusion, no ambiguity. This works because both aliases are sibling subtypes of dict.

The same upcast restrictions apply to match

The same logic applies for match and the is operator.

Having MerkleProofCell | T, you can test for MerkleProofCell and cell.
Having cell | T, you can test for cell, but NOT for MerkleProofCell (no downcast).

This is identical to method calls.

fun demoWrong(c: cell | int) {
    match (c) {
         MerkleProofCell => ...     // error, use `cell`
         int => ...
    }
}

fun demoOk(m: MerkleProofCell | int) {
    match (m) {
        cell => ...     // ok, upcast
        int => ...
    }
}

Subtypes are assignable, nothing else changes

MerkleProofCell and cell are still assignable in both directions, because they have identical runtime type:

var c: cell = merkleCell;
var m: MerkleProofCell = c;
// or
var m = c as MerkleProofCell;

Similarly, AssetsDict and BalanceDict are assignable: they are both cell | null at runtime.

Similarly, an alias for int has operator +, etc.

type MyInt = int

fun sum(a: MyInt, b: MyInt): MyInt {
    return a + b
}

Union types are still tagged unions with aliases erased: both slice | int and slice | MyInt work identically at runtime. A union int | MyInt is invalid (compilation error).

All in all: subtypes only tidy up method receivers, tightening the type system. Runtime behavior does not change.

Implementation details

There is a new convention "SubtypeDistance":

type IntAlias = int           // distance 1 from int
type DeeperInt = IntAlias     // distance 2 from int, 1 from IntAlias

Relation is structural: d(array<DeeperInt>,array<int>) = 2.

Identical types have distance 0: d(int,int) = 0, d(cell,cell) = 0.

Incomparable types don't have a valid value: d(int,slice) = d(int8,int16) = incomparable.

For a call object.method(), the fun receiver.method is available only when d >= 0. All available overloads are sorted by distance, alongside other criteria.

See type-system.cpp and overload-resolution.cpp.


Colorful compilation errors with multi locations

Compilation errors become more user-friendly.

Multi-locations in errors

For many errors the compiler now shows a note/hint pointing to other code. This is useful: you understand the error better and can quickly navigate to the related declaration in the IDE.

Example 1: you have fun calcSum(x,y) but call it with 3 arguments:

error: too many arguments in call to `calcSum`, expected 2, have 3
    --> /path/to/file.tolk:6:30
     |  in function main
     | 
   6 |     return calcSum(100, 200, 300);
     |                              ^^^

note: function declared here
    --> /path/to/file.tolk:1:5
     | 
   1 | fun calcSum(x: int, y: int) {
     |     ^^^^^^^

Example 2: can not @inline because of return in a loop:

error: function `heavy` can't be inlined
    --> /path/to/file.tolk:4:5
     | 
   4 | @inline fun heavy(data: HeavyData): int? {
     |             ^^^^^
hint: `@inline` is impossible, because of `return` inside a loop

note: this prevents inlining
    --> /path/to/file.tolk:11:13
     | 
     |         if (elem < 0) {
  11 |             return null;
     |             ^^^^^^^^^^^

Example 3. You have two messages and calculate a new counter

struct Increment {
    byValue: int32
}

struct Reset {
    initialValue: int64
}

fun handle(msg: Increment | Reset) {
    var newCounter = match (msg) {
        Increment => curCounter + msg.byValue,
        Reset => msg.initialValue,
    }
}

What's the problem? The type of newCounter is non-obvious: a+b is int, but initialValue is int64, what do you want?

An error is very descriptive here:

error: type of `match` was inferred as `int | int64`; probably, it's not what you expected
    --> /path/to/file.tolk:12:22
     |  in function handle
     | 
  12 |     var newCounter = match (msg) {
     |                      ^^^^^
hint: assign it to a variable `var v: <type> = match (...) { ... }` manually

note: this branch is `int`
    --> /path/to/file.tolk:13:9
     | 
  13 |         Increment => curCounter + msg.byValue,
     |         ^^^^^^^^^

note: this branch is `int64`
    --> /path/to/file.tolk:14:9
     | 
  14 |         Reset => msg.initialValue,
     |         ^^^^^

When using --json-errors (for IDEs, background tooling, and LLM agents), notes are reported in secondary_locations array for each error.

They are also colorful!

tolk-err-console-light-dark

The CLI option --colors accepts auto|always|never (default "auto"). May also be disabled by a non-empty env NO_COLOR.

For lib mode (Acton and wasm), use colorizeErrors json option (it's bool, default false).

With JSON errors, it's always off.

The compiler no longer emits warnings

Earlier the compiler could print some warnings, like "condition is always false", that did not prevent compilation, just existed in stderr.

Now the compiler emits no warnings. The code either compiles or an error is reported.

Why no warnings? We decided that warnings should be a responsibility of an external linter. The acton check command (docs) already has dozens of analysis rules. It's better to move all warnings there, to have a unified way of suppressing them.


Other changes

A list of smaller changes that don't require dedicated sections.

Get methods without body (ABI only)

get fun calc_sub_jetton(invite_id: int): CalcJettonReply

Such getters are output to ABI only. They are available only with --allow-empty-get-fun option (or allowEmptyGetFun in wasm mode).

Purpose: describe ABI of FunC contracts. Earlier you had to write a dummy implementation. All other properties (besides get fun) are already convenient to describe in the contract header.

Acton passes this option when processing *.types.tolk files (e.g. in our ABI catalog).

lazy f() for non-trivial f

Such code is now valid if smth has no return statements:

fun loadStorage() {
    smth;
    return XXX.fromSlice(...);
}

// somewhere
val st = lazy loadStorage();

Previously, loadStorage could not be called with lazy, because it contained some statements besides return.

Example: migrate c4 account storage before doing regular load

fun Storage.load() {
    if (getC4Revision() != LAST_REVISION) {
        runMigration();
    }
    return Storage.fromCell(contract.getData());
}

get fun get_some_field() {
    // performs migration, then lazily parses c4
    val storage = lazy Storage.load();
    return storage.someField;
}

ton("0.05") is deprecated: use grams("0.05")

Function grams was introduced in Tolk v1.4.2, allowing both during the transition period.

Now ton becomes deprecated. Replace it with grams.

Drop bytesN type, only bitsN is valid

Use bits512 instead of bytes64 and so on.

The bytesN alias for bits{N*8} was removed. It was a mistake to add it earlier. In practice, this creates cognitive load.

A few bug fixes found by LLM fuzzing

Traditionally, a continuous background fuzzer pointed out some flaws in corner cases. Traditionally, none of them can be exploited in real contracts.

Examples:

  • a call to never function inside reflection
  • too many global variables crashed Fift
  • collision of get fun and manual @method_id
  • and so on.

Optimization for if-else before return

Look at this snippet:

fun demo(c: int): int {
    var result = 0;
    if (c == 0) { result = 1 }
    else        { result = 2 }
    return result;
}

Its Fift code now is

IFJMP:<{
  2 PUSHINT
}>
1 PUSHINT

Notice: no IFELSE, no extra continuations, no 0 EQINT, branches are swapped.

A new optimization materializes return inside if-else branches when if-else is followed by an immediate return. This works at IR level, after all functions have been inlined.

Preparing the kernel for Tolk v2.0

In July, I experimented with a new compiler backend (for Tolk v2.0). That backend will fully replace the FunC kernel still living with us, and eliminate the need of Fift.

These experiments pointed out some AST-level features that are tightened to FunC's backend. That's absolutely okay for the current implementation (where I rely on later FunC-inherited passes) but will not apply when switching the backend.

Tolk v1.5 makes some changes in advance.

For example, callxargs are now evaluated at the AST level rather then during Fift codegen step. The AST structure is kept immutable: previously, hidden lazy load statements were inserted to AST; now a LazyLoadPlan per-function is build aside. The built-in functions compiler API has also been completely redesigned to be replaceable. And so on.

The optimization_level option has been removed. Now there is no way to disable peephole optimizations. Reason: it will not make sense for the new backend. And anyway, we need the compiler to produce the same bytecode regardless of options.

The end user notices nothing here. But from the kernel's perpective it will require less refactoring for the future Tolk v2.0.

Related pull requests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Tolk Related to Tolk Language / compiler / tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants