Precedence-climbing (Pratt) expression parsing over a runtime-mutable
operator table -- extracted from a pattern already hand-rolled once,
OpExprTest.Operators.climb/6 (test/support/opexpr_operators.ex),
the worked example proving Grammar.IR.Custom/@native(...) rule-
position dispatch works at all. That fixture only ever needed infix
chains; this generalizes to prefix and postfix too, since those are
exactly what Track 1's own motivating scenarios need (Prolog's
op/3 supports fy/fx prefix and xf/yf postfix operators
alongside infix ones, Haskell fixity declarations are infix-only but
mixfix notation is not).
Completely IR-agnostic, like Ichor.Toolkit.Codegen/Result: pos,
the stream, and the parsed value are all opaque to this module.
peek_op returning {op_name, pos_after_op} rather than just
op_name is deliberate, not incidental -- an operator token may sit
behind skippable trivia (whitespace, comments; @native(...) nodes
are opaque to Aether's own @skip splicing, so a caller has to handle
this itself, same as climb/6 already did), so where the operator
actually ends can't be assumed to be a fixed offset from where the
search for it started. parse_primary is trusted to do its own
leading-trivia handling for the same reason.
The infix/postfix ambiguity
An operator registered as both infix and postfix at once is
genuinely ambiguous: after parsing a left operand and seeing that
token, nothing short of looking further ahead can tell whether an
infix use (expecting a right operand next) or a postfix use (expecting
nothing) was meant. This is exactly why ISO Prolog's own op/3
forbids defining an atom as both infix and postfix -- the same
operator serving as prefix and infix (unary vs. binary -) or
prefix and postfix has no such problem, since prefix is only ever
attempted before a left operand exists and infix/postfix only after,
two mutually exclusive positions.
The table itself doesn't reject the combination -- it has no way to
know at build time whether a caller will later supply the means to
resolve it, since callbacks are only ever known at parse/4 call
time, not baked into the table. Instead, parse/4 only raises when it
actually encounters a token where both fixities are simultaneously
viable at the current precedence: if callbacks includes
can_start_operand?, it's consulted (does a valid operand start right
after the operator? infix if so, postfix if not); if it's absent,
parse/4 raises ArgumentError naming the operator, rather than
silently misparsing one way or the other.
Summary
Functions
Registers op_name as an infix operator at prec with associativity assoc (default :left).
An empty operator table.
Parses one expression starting at pos, only accepting an infix/postfix
operator whose precedence is at least min_prec (default 0 -- pass
a table entry's own precedence, bumped per associativity, on the
recursive call that parses an operator's own operand).
Registers op_name as a postfix operator at prec.
Registers op_name as a prefix operator at prec.
Types
@type assoc() :: :left | :right | :none
@type fixity() :: :prefix | :infix | :postfix
@type op_name() :: term()
@type table() :: %{ optional(op_name()) => %{ optional(:prefix) => non_neg_integer(), optional(:infix) => {non_neg_integer(), assoc()}, optional(:postfix) => non_neg_integer() } }
Functions
@spec infix(table(), op_name(), non_neg_integer(), assoc()) :: table()
Registers op_name as an infix operator at prec with associativity assoc (default :left).
@spec new() :: table()
An empty operator table.
Parses one expression starting at pos, only accepting an infix/postfix
operator whose precedence is at least min_prec (default 0 -- pass
a table entry's own precedence, bumped per associativity, on the
recursive call that parses an operator's own operand).
@spec postfix(table(), op_name(), non_neg_integer()) :: table()
Registers op_name as a postfix operator at prec.
@spec prefix(table(), op_name(), non_neg_integer()) :: table()
Registers op_name as a prefix operator at prec.