Repository navigation
Tolk v1.5: break/continue in loops, smarter optimization, colorful errors, and preparation for v2.0 - #2577
Merged
Conversation
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
@inlinefunctions with multiple returnsbreak/continuein loops, finally for TVMInline with multiple returns
TLDR:
if (cond) { return false } ...returnfrom loops, from try/catch, or in unexpected positions@inlineannotation either inlines or fires an errorIn detail:
Before, "return in the middle" prevented inlining
Since Tolk v1.0, the compiler supported auto-inlining simple functions.
There was also an
@inlineannotation which told the compiler: "if possible, inline it; otherwise, defer inlining to Fift with INLINECALLDICT".What could prevent a function from being inlined?
var callback = someFnThe last point is crucial. Only a single
return, the last statement, was allowed: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
returninside that body can not emit RET, since RET would exit the caller. Instead,returnis "assign to OUT IR vars".When inlining, its body looks like
What's the problem with multiple returns? Assigning OUT is easy. The hard part is: everything below must not execute, and there is no
gotoin TVM to skip it.For example, take this function:
If we just replace
returnwithOUT, it will be INCORRECT:Then how can we achieve a correct result without RET and GOTO? The answer: rewrite function body with if/else:
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:
gets translated to
Example 2:
gets translated to
Example 3:
gets translated to
What is supported exactly
Supported:
if/elsewhere both branches terminate (or neither branch contains areturn)if/elsewhere one branch terminates and the other does not: tail goes to the othermatchwhere all arms terminate (or neither branch contains areturn);if it's not exhaustive, tail goes to its implicit
elsematchwhere all but one arms terminate, and it's exhaustive: tail goes to non-terminatingNOT supported:
if (cond) { if (cond) { return 1 } }returnin other positions: in a loop, in try/catchreturnhidden inside an expression, e.g.if (match(subj) { 0 => return 0 })In one sentence:
Inlinable <=> every if/match that contain
returnleaves 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.@inlineannotation fires if impossibleBefore, if the compiler could not inline a function marked
@inline, it silently deferred inlining to Fift withINLINECALLDICT. 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'sinlineattribute works, by the way.Now, since practical cases with early returns are covered, the compiler fires an error if
@inlineis used but cannot be applied.now gets an error
A reason is printed for all unsupported kinds: "because of
returninside a loop" and so on.So,
@inlineeither 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
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 everyreturnin the middle?"Its outcome:
InlineReturnPlan: "here are the if/match nodes that route the flow"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 sameInlineReturnPlanis used. During loweringif/else, the tail is collected and inserted into a routing branch.Example:
Here
yyyandzzzare rerouted into implicitelseof the innerif— not at their direct positions:See
struct FallthroughTail,TailResult, andemit_tail_statements. The logic is quite involved because it has to deal with debug marks, scoping, and implicit returningmutateparams in the end.Support
break/continuein loopsTLDR:
breakinwhile,do-while,repeatcontinuewith some limitationstry-catchbreakandreturnin the same loop (they both are RETALT in loop's body)In detail:
breakis supportedbreakis allowed anywhere excepttry-catch: in deepif, in lazymatch, etc.With nested loops,
breakfrom the inner works correctly also.How
breakworksTVM already has
*BRKcounterparts for loops:WHILE→WHILEBRK,AGAIN→AGAINBRK, etc. These instructions savec1continuation. As a result,RETALTinside a*BRKloop actually breaks it, jumping after the loop.So, when a loop contains direct
break, Tolk generates a*BRKinstruction. Abreakstatement is essentiallyRETALT.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,
breakis quite simple — surprisingly, much simpler thancontinue.Limitation:
break+returnnot supportedWhen both are used in a loop, it's an error
Unsupported, because both are
RETALTat the TVM level (working differently forXXXandXXXBRKin TVM).continueis supportedcontinueis allowed almost everywhere excepttry-catch. There are some limitations, not visible in practice.continuejumps to condition (exactly as in other languages). If the condition is false, the next iteration will not start.With nested loops,
continuefrom the inner works correctly also.How
continueworksInteresting:
breakis MUCH simpler thancontinue, because TVM already has*BRKinstructions that work organically withRETALT.Note that we can NOT use
RETforcontinue: it will not work inside IFs and other continuations.Then how?
The answer is surprising:
continuein loops is THE SAME as an earlyreturnin inline functions! Look:gets translated to
Example 2:
gets translated to
Example 3:
gets translated to
It's exactly the same as
returnin the middle! The filesinline-return-analysis.cppandloop-control-analysis.cppare almost identical, producingInlineReturnPlanandLoopContinuePlan, 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
continueThat's why limitations for
continueare identical toreturnwhen inlining.Unsupported example:
This can not be rewritten with if-else, because 2 branches terminate, 3 do not.
In one sentence:
Continuable <=> every if/match that contain
continueleaves 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:
@pureannotation remains only forasmfunctionsIn detail:
Previously,
@purecalls could be removedTolk compiler still has a FunC kernel inside. FunC's behavior relies on the
impurekeyword. Once forgotten, a call to that function could be optimized out. That was a common mistake when using FunC:To avoid this common mistake, Tolk inverted the annotations.
impurebecame the default. To make an unused result removable, you had to mark the function@pure.@pureis 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.Even when assigned, it was removed if the variable remained unused.
While these examples can seem obvious, the following is less obvious:
What does
@puremean, and what purposes does it serve?The concept of "purity" is very vague. For example,
__throw(123)anddebug.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.fromSliceorcreateMessage, 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:
r.loadValue()aboveReally! At least half of the bug reports I get are related to unexpected code removal.
Examples:
s.loadBool()— it's removed, no exception thrownSomeStruct.fromSlice(s, {assertEnd:false})without using the result — also removed as unusedvar num = x << 2and don't usenum— calculation removed, no exception onx > MAX_INT/2-x, unused negation also removed:Removed, because
-xis internally an__unary_minus(x)function which is@pure. That's why forx = -MAX_INTit 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
@pureannotation is now applicable toasmfunctions only. It means: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.
@pureis still not enough in corner cases, thoughThe compiler reorders calculations when it's safe, to produce fewer stack shuffles. Example:
constructreturns aPoint— 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:
If
calcXandcalcYareasmfunctions, 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.@pureand 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 = intcreates a subtype ofint— a directional relation:fun int.method, you can call both5.method()andmy5.method()fun MyInt.method, you can callmy5.method()but NOT5.method()In detail:
Earlier, a type alias was interchangeable with its underlying type
The
typesyntax creates a type alias. It worked the same way as in TypeScript: everywhere you useint, you can useMyInt, and vice versa.For example, a merkle-proof cell is just a cell, you create an alias to emphasize it:
The
prooffield works exactly like acell. Everything still works if it's declared ascell. You introduceMerkleProofCellfor readability only.Then you want
MerkleProofCellto have a special method:The problem is: this method could also be called on any
cell, becauseMerkleProofCellis fully interchangeable withcellin all directions.The problem with custom serializers
From Tolk v1.0, any type alias could overload serialization methods:
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 = Pointto overloadPointA.packToBuilder.It was a hack, because once you overload
PointA.packToBuilder, it actually changed the behavior of a struct. Why? The same reason ascell.validateIsSpecialresolves for anycell. Similarly, formyPoint.toCell()a methodPointA.packToBuilderwas applicable.I allowed overloading
packToBuilderfor structs directly to avoid confusion, but this actually made things even worse. What if someone creates a struct, an alias to it, and overloadspackToBuilderfor both of them? What if that struct/alias is generic? And so on.Does anyone actually need to overload
packToBuilderfor both a struct, an alias referencing that struct, then usematchover 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
packToBuilderspecifically.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->intis allowed, butint->IntAliasis not.This allows type aliases to declare methods not applicable from original types. In fact, this behavior is better than before.
Example 1:
Example 2:
Example 3:
The last example is also quite important, because once
Msg2is implemented, behavior could have changed, sinceAllowedMessageswould 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:
By the way, subtypes correctly handle this case:
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
matchThe same logic applies for
matchand theisoperator.Having
MerkleProofCell | T, you can test forMerkleProofCellandcell.Having
cell | T, you can test forcell, but NOT forMerkleProofCell(no downcast).This is identical to method calls.
Subtypes are assignable, nothing else changes
MerkleProofCellandcellare still assignable in both directions, because they have identical runtime type:Similarly,
AssetsDictandBalanceDictare assignable: they are bothcell | nullat runtime.Similarly, an alias for
inthas operator+, etc.Union types are still tagged unions with aliases erased: both
slice | intandslice | MyIntwork identically at runtime. A unionint | MyIntis 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":
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(), thefun receiver.methodis available only whend >= 0. All available overloads are sorted by distance, alongside other criteria.See
type-system.cppandoverload-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:Example 2: can not
@inlinebecause ofreturnin a loop:Example 3. You have two messages and calculate a new counter
What's the problem? The type of
newCounteris non-obvious:a+bisint, butinitialValueisint64, what do you want?An error is very descriptive here:
When using
--json-errors(for IDEs, background tooling, and LLM agents), notes are reported insecondary_locationsarray for each error.They are also colorful!
The CLI option
--colorsacceptsauto|always|never(default "auto"). May also be disabled by a non-empty envNO_COLOR.For lib mode (Acton and wasm), use
colorizeErrorsjson 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 checkcommand (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)
Such getters are output to ABI only. They are available only with
--allow-empty-get-funoption (orallowEmptyGetFunin 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 thecontractheader.Acton passes this option when processing
*.types.tolkfiles (e.g. in our ABI catalog).lazy f()for non-trivial fSuch code is now valid if
smthhas noreturnstatements:Previously,
loadStoragecould not be called withlazy, because it contained some statements besidesreturn.Example: migrate
c4account storage before doing regular loadton("0.05")is deprecated: usegrams("0.05")Function
gramswas introduced in Tolk v1.4.2, allowing both during the transition period.Now
tonbecomes deprecated. Replace it withgrams.Drop
bytesNtype, onlybitsNis validUse
bits512instead ofbytes64and so on.The
bytesNalias forbits{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:
neverfunction inside reflectionget funand manual@method_idOptimization for
if-elsebefore returnLook at this snippet:
Its Fift code now is
Notice: no
IFELSE, no extra continuations, no0 EQINT, branches are swapped.A new optimization materializes
returninsideif-elsebranches whenif-elseis followed by an immediatereturn. 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,
callxargsare 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 aLazyLoadPlanper-function is build aside. The built-in functions compiler API has also been completely redesigned to be replaceable. And so on.The
optimization_leveloption 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