Infix arithmetic to reverse Polish notation, and back to a number: Tokenize, ToRPN, EvalRPN, Eval, FormatRPN, Operators.
1import"gno.land/p/moul/x/daily/shuntingyard/v0" 2 3shuntingyard.Eval("2+3*4")// 14, nil 4shuntingyard.Eval("-3^2")// -9, nil a prefix minus binds looser than ^ 5shuntingyard.Eval("1/0")// 0, ErrDivByZero 6shuntingyard.Eval("2^64")// 0, ErrOverflow 7 8toks,_:=shuntingyard.Tokenize("(2+3)*4") 9rpn,_:=shuntingyard.ToRPN(toks)10shuntingyard.FormatRPN(rpn)// "2 3 + 4 *"
A realm that wants to take a formula from a caller has two bad options and this
one. Hard-coding the arithmetic means a new package path for every new formula,
and a public path can never be redeployed. Accepting a pre-built RPN string
pushes the parsing onto whoever is sending the transaction, who then has to be
trusted to have parsed it the same way the realm would have. Parsing infix on
chain keeps the formula a piece of data and the meaning a piece of code.
Every arithmetic result is checked, deliberately. An expression that would
wrap int64 returns ErrOverflow rather than a plausible wrong number. The
usual reason to evaluate a caller's formula is to compute an amount, and a
wrapped amount is indistinguishable from a correct one until it is spent.
Four behaviours worth knowing before you use it:
Nothing panics. Every refusal is an error value, including a malformed
expression, an overflow and a division by zero. A Render that evaluates a
stored expression therefore cannot be made permanently unreadable by one bad
input, which matters because the page cannot be redeployed to fix it.
The prefix minus is its own operator, u-. It takes one operand and sits
below ^ in the precedence table, so -3^2 is -9 and not 9. Treating it
as a flag on binary - is the classic way to get that wrong, and the RPN is
where it becomes visible: 3 2 ^ u-, never 3 u- 2 ^.
Parsing and evaluation are separate passes.ToRPN answers whether an
expression is well formed; the RPN it returns is a bounded program a realm can
store and re-run without parsing again. EvalRPN re-validates what it is
given rather than trusting it, because by the time a stored program is read
back it is an input like any other.
Integers only, and division truncates toward zero.-7/2 is -3 and
-7%2 is -1. There is no float type worth having on chain: a Render has
to be byte-identical on every validating node, and that is exactly what
floating point does not promise. A caller who needs a fraction scales by a
power of ten and divides at the end.
Syntax errors carry the byte offset where the problem was noticed
(shuntingyard: unexpected number at offset 2), so a realm can point at the
character rather than reporting that something, somewhere, was wrong.
MaxTokens caps an expression at 1024 tokens. Evaluation is linear in the token
count, so the cap is not about time: it is there so a caller cannot size the two
stacks from unbounded input and make the allocation itself the attack.
The operator set is + - * / % ^, parentheses, and the two prefix forms. There
are no variables, no functions and no floats. Adding an operator is a row in
operators, which Operators() hands out as a copy.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Overview
Package shuntingyard turns an infix arithmetic expression into reverse Polish notation and evaluates it over int64, with Dijkstra's shunting-yard algorithm (1961).
A realm that wants to take a formula from a caller has two bad options and this one. Hard-coding the arithmetic means a new package path for every new formula, and a public path can never be redeployed. Accepting a pre-built RPN string pushes the parsing onto whoever is sending the transaction, who then has to be trusted to have parsed it the same way. Parsing infix on chain keeps the formula a piece of data and the meaning a piece of code.
Three properties make it usable for that rather than only interesting:
Every arithmetic result is checked. An expression that would wrap int64 returns ErrOverflow instead of a plausible wrong number, which matters because the usual reason to evaluate a caller's formula is to compute an amount.
Nothing panics. Every refusal is an error value, so a Render that evaluates a stored expression cannot be made permanently unreadable by one bad input.
Parsing and evaluation are separate. ToRPN answers whether an expression is well formed, and the RPN it returns is a bounded program a realm can store and re-run without parsing it again.
Integers only, deliberately: see the package README.
Operator spellings, as they appear in a Token. The two unary forms are separate operators rather than a flag, because a prefix minus and a binary minus have different arities and different precedence, and conflating them is the classic way a shunting-yard implementation evaluates -3^2 as 9.
MaxTokens bounds how long an expression may be, so a caller cannot size the two stacks from unbounded input and make the allocation itself the attack. It is a token count, not a byte count: whitespace and long literals are free.
1var(2ErrDivByZero=errors.New("shuntingyard: division by zero")3ErrOverflow=errors.New("shuntingyard: int64 overflow")4ErrNegExponent=errors.New("shuntingyard: negative exponent")5ErrTooLong=errors.New("shuntingyard: too many tokens")6ErrMalformedRPN=errors.New("shuntingyard: malformed RPN")7)
EvalRPN evaluates a reverse Polish token stream over int64.
It re-checks everything ToRPN guarantees, because the RPN is the part a realm would store and re-run, and a stored program is an input like any other by the time it is read back.
ToRPN converts an infix token stream to reverse Polish notation, rejecting anything that is not a well-formed expression.
The validity check is one bit of state: whether an operand or an operator is expected next. That single bit is what rejects "1 2", "1+" and "()", and it costs nothing on top of the conversion the function is doing anyway.
Tokenize splits an infix expression into tokens, resolving a leading '+' or '-' to its unary form (OpPos, OpNeg) from what came before it.
It validates characters and literals only. Whether the tokens form an expression is ToRPN's question, because that is the pass that knows what may follow what.
Token is one lexical unit of an expression. Num is meaningful only for KindNumber and Op only for KindOperator. Pos is the byte offset the token started at, kept so an error can name a place rather than a symbol.