OpenResty Fanlang™ User Manual
Fanlang is an optimizing compiler that translates a Perl 6 / Raku dialect into standalone Lua code targeting OpenResty. It is designed for writing expressiveness-heavy software (DSL compilers, text-processing tools) while keeping the runtime characteristics of Lua.
Table of Contents
Overview
Fanlang compiles .fan source files into .lua output that runs under
OpenResty’s resty command. The source dialect is close to Raku, but it
intentionally diverges from Raku in a number of places; the differences are
documented in this manual.
The motivations behind fanlang are:
- Productivity: Perl 6 / Raku is substantially more expressive than Lua, especially for DSL compilers and other text-heavy tooling.
- Delivery: we want to ship source code that we did not write by hand, protecting intellectual property while still giving customers runnable code.
- Speed: fanlang compiles quickly.
Usage
$ cat a.fan
my $who = "world";
say "hello, $who!"
$ fan a.fan # compile + run
hello, world!
$ fan -c a.fan # compile only; writes a.lua
$ resty a.lua
hello, world!
$ fan -e 'say "hi";' # one-liner
hi
The fan command accepts these options (matching fan --help):
| Option | Meaning |
|---|---|
-c, --compile | compile only (without execution) |
-D NAME[=VALUE] | define a macro (repeatable); used by #ifdef / #ifndef. Value is the empty string if omitted |
-d, --dump-ast | dump out the AST |
-e CODE | execute the Fan code directly |
--echo-back | echo back FanLang code from the AST |
--gen-module | generate a reusable library module in the target language |
-h, --help | print this usage |
-I PATH | specify the search path for loading user-defined fan modules (repeatable) |
--no-runtime-check | disable runtime check |
-O N | enable optimization with the level N |
-o FILE | specify the output file name |
--runtime-version | specify the runtime version |
--test-grammar | test grammar only |
-v, --verbose | be verbose |
Language Basics
Variables and scope
my $x = 42; # scalar
my @a = (1, 2, 3); # array
my %h = foo => 1, # hash
bar => 2;
my ($a, $b) = (10, 20); # list assignment
my Bool ($ok, $done); # multiple typed declarations
my Int $n = 0; # typed scalar with default
my Bool %seen{Str}; # typed hash with typed keys
Variables are lexically scoped with my. Sigils ($, @, %) are part of the
name, so $a, @a, and %a can coexist as three distinct variables.
Use our instead of my for a package-scoped variable — useful for
exposing a class-level handle:
unit class Bar;
our $f; # referable as $Bar::f from outside
submethod TWEAK { $f() }
Binding vs assignment
=copies the right-hand side into the variable.:=binds the variable to the same container on the right-hand side (no copy). Common after transforming an array in place:@!items := transform(@!items);.
The topic variable $_ is implicit in for, map, grep, given, and
similar constructs. .method with no invocant is sugar for $_.method.
Redeclaring $_ with my $_ = ... is an error.
Conditionals
if $x > 0 { say "positive" }
elsif $x == 0 { say "zero" }
else { say "negative" }
unless $ok { die "not ok" }
say "big" if $x > 100; # statement modifier
unless does not accept elsif/else branches.
Loops
my @a = ("a", "b", "c", "d");
for @a -> $elem { say $elem }
for @a -> $k, $v { say "$k=$v" } # pairs per iteration, prints "a=b\nc=d"
for @a { say $_ } # implicit $_
while $i < 10 { $i++ }
repeat { ... } while $cond;
repeat { ... } until $cond;
loop (my $i = 0; $i < 3; $i++) { say $i } # C-style
next and last control loop flow; both are available as postfix statement
modifiers (next if ..., last if ...).
Given / when
given $line {
s/^\s+//;
s/\s+$//;
say $_;
}
for @items {
when 'hello' { say "greeting" }
when 1 { say "one" } # smart-matches; '1' also matches
when Int { say "integer" }
when /^a/ { say "match" } # regex matched against $_
}
when is used inside iteration to smart-match the current topic.
Operators
- Arithmetic:
+ - * / % ** - Bitwise:
+& +| +^ +< +>; prefix+^for bitwise negation - Stringification (prefix):
~—say ~32prints"32" - Concatenation (infix):
~ - Replication:
x(string); compound formx= - Comparison:
== != < <= > >=(numeric),eq ne lt le gt ge(string),=== !===(identity) - Three-way numeric comparator:
<=>(returns-1,0, or1) — pairs naturally withsort { $^a <=> $^b } - Logical:
&& || ! - Defined-or:
//— returns the right operand when the left isNil(my $level = $user-arg // 1;) - Range:
.. - Pair:
=> - Smart match:
~~ - Junctions:
any(...),all(...), infix|,& - Ternary:
?? !!
Operator precedence
From tightest binding to loosest; only currently-implemented operators are listed:
| Level | Operators | Notes |
|---|---|---|
| term | literal, variable, self, *, (expr) | atoms |
| postfix | .meth, .(...), .[...], .{...}, .<...>, indexing, invocation | |
| prefix | ! - + ~ ? +^ | unary |
| 1 | ** | exponent |
| 2 | * / % +& +< +> | multiplicative |
| 3 | `+ - + | +^` |
| 4 | x | string replication |
| 5 | ~ | concatenation |
| 6 | & | junction (all) |
| 7 | ` | ` |
| 8 | == != < <= > >= === !=== ~~ !~~ <=> eq ne lt le gt ge | relational, not chainable |
| 9 | && | logical and |
| 10 | ` | |
| 11 | .. | range |
| 12 | ?? !! | ternary |
| 13 | => | pair |
| 14 | , ; | separator |
Whatever closures
The * token in value position builds an implicit closure. The arity of
the closure is the number of * tokens used as atoms (each becomes one
positional parameter, bound in left-to-right order):
my $f = * - 2; # sub ($a) { $a - 2 }
my $g = * * 4 - 1; # sub ($a) { $a * 4 - 1 } (middle * is infix)
my $h = * * 4 - *; # sub ($a, $b) { $a * 4 - $b }
my $k = * * * - *; # sub ($a, $b, $c) { $a * $b - $c }
substr($s, *-2); # "last 2 chars" — a closure passed as an arg
Ranges
my $r = 1..5;
say $r.min; # 1
say $r.max; # 5
say $r.elems; # 5
for 1..3 -> $i { say $i }
my @xs = flat 5..7; # [5 6 7]
Junctions
if $x == 1 | 2 | 3 { ... } # any of these values
if 1 & 2 & 3 < $x { ... } # all of these values are less than $x
if any(@forbidden) eq $name { die "bad name" }
Junctions bind tighter than comparison operators: $x == 1 | 2 | 3
parses as $x == (1 | 2 | 3). They appear as an operand to a comparison,
not as a combiner of boolean expressions. To combine two booleans, use the
regular logical operator &&:
if $x > 0 && $x < 10 { ... } # NOT `$x > 0 & $x < 10`
Strings and interpolation
Double-quoted strings interpolate:
"plain $var" # scalar
"{$expr + 1}" # arbitrary expression
"items: @items[]" # array elements (space-joined)
"value of %h<key>" # hash element
"first match: $0" # regex capture after a match
Single-quoted strings are literal. Heredocs use q:to/DELIM/ ... DELIM.
Quoted word lists
my @w = qw/foo bar baz/;
my @x = qw{foo bar};
my @y = qw(foo bar);
Flexibly-quoted string/word literals (q, qq, qw) accept a variety of
delimiter pairs: /, !, |, ,, #, ;, $, {}, [], <>, ().
Paired brackets use the matching closer; single-char delimiters repeat.
Subroutines and Parameters
sub greet ($name, $greeting = "hello") {
say "$greeting, $name!";
}
greet("world"); # parenthesized call
greet "world", "hi"; # list-op call
greet: "world", "hi"; # colon call — arglist runs to end of expr
The three call forms are interchangeable for subs. The colon form is the house style for method calls (particularly constructors), because it binds tighter than what follows on subsequent lines:
my $node = MyLang::FuncCall.new:
func => $fn,
args => @args,
line => $line;
Parameter features:
- Optional:
sub f ($a, $b?)—$bisNilwhen absent - Defaults:
sub f ($a = 42) - Type constraints:
sub f (Str $s, Num $n)— enforced at call time - Slurpy (flattening):
sub f (*@args)— list arguments flatten into@args - Slurpy (non-flattening):
sub f (**@args)— arguments arrive as-is - Anonymous subs:
my $f = sub ($a) { $a + 1 }; invoke with$f(1)or$f.(1) - Placeholder params:
sort { $^a <=> $^b }, @items—$^a,$^bauto-declare positional parameters inside a block, bound in name order
Export a sub from a module with sub name is export { ... }.
Return values:
sub two-values { return 1, 2 } # explicit list
sub implicit { $last-expr }
Return-type declaration
Annotate the return type with -->:
method as-lua (Int $level --> Str) { ... }
sub stringify ($x --> Str) { ~$x }
Comparing against several values with junctions
eq (a | b | c) is the compact way to test a string against multiple
alternatives:
if $op eq ('~' | 'x') { ... } # same as: $op eq '~' || $op eq 'x'
if $kind eq ('+' | '-' | '*' | '/') { ... }
Any infix operator accepts the junction on its right-hand side.
Classes and Objects
class Point {
has Num $.x is required; # must be supplied to .new
has Num $.y is required is rw; # read-write accessor
has Str $!label; # private attribute
has Num %!cache{Str}; # hash with typed keys and values
has Str @.tags; # typed array attribute
submethod TWEAK { # post-construction hook
$!label //= "({$!x},{$!y})";
}
method distance-to (Point $other --> Num) {
my $dx = $.x - $other.x;
my $dy = $.y - $other.y;
sqrt($dx*$dx + $dy*$dy);
}
method !internal ($v) { ... } # private method
}
class ColoredPoint is Point {
has Str $.color = "black";
}
my $p = Point.new(x => 1, y => 2);
say $p.distance-to(Point.new(x => 4, y => 6));
$p!internal(3); # only valid inside Point
File-level unit form
A file may contain exactly one class, module, or grammar with no outer
braces by using the unit form:
unit class MyLib::Thing; # the rest of the file is the class body
unit module MyLib::Util;
unit grammar MyLib::Expr;
Multiple inheritance
Repeat is for each parent:
unit class ActionDecl is ProcDecl is ActionChain;
Attribute traits
is required— a value for this attribute must be supplied to.new.is rw— generate a read-write accessor.- Default value — written as
has Num $.x = 0. - Typed collections —
has Type @.name,has Type %.name{KeyType}.
Construction hooks
submethod BUILD (:$!a, :$!b) { ... }— runs before default attribute population. Rarely needed.submethod TWEAK { ... }— runs after.newhas populated all attributes. Use it for validation and derivation. This is the hook you will reach for 99% of the time.
Method dispatch
$obj.method(args)— normal invocation$obj."$name"(args)— dynamic method by string$obj."{$expr}"(args)— dynamic method by expressionself!private(args)— private method (only inside the class)- Methods can be declared
submethodfor initialization-style methods that do not participate in inheritance dispatch
Introspection
value.WHAT— type object, prints as(Array),(Hash),(Num), etc.value.isa(Type)orvalue.isa('TypeName')— type testvalue.ACCEPTS(other)— smart-match acceptance protocolobj.HOW.name($obj)— class nameobj.HOW.attributes($obj)— list ofAttributemeta-objects, each with.name,.bare_name,.readonly,.has_accessor,.get_value($obj),.set_value($obj, v)obj.clone— shallow clone;obj.clone(field => new-value)overrides fields
Modules
# file MyLib.fan
unit module MyLib;
sub helper ($x) is export { $x * 2 }
class MyLib::Thing { ... }
# file main.fan
use MyLib;
say helper(3); # 6
Alternative block form:
module MyLib {
sub helper ($x) is export { ... }
}
Module lookup uses the search paths passed via -I. The global variable
@*ARGS must not be referenced from any file loaded via use.
Importing a Lua module
use :lua Mod::Name; makes a Lua module available from generated code. It
does not load a .fan file; instead, at runtime the generated Lua does
require("Mod.Name"). Use this to call into hand-written Lua libraries or
shared runtime helpers from a fanlang compiler:
use :lua MyLang::Symtab;
use :lua Resty::Core;
If the first require fails, fanlang retries with the module name
fully lower-cased before raising the error — so
use :lua Foo::Bar::Baz also matches foo.bar.baz on disk.
Regular Expressions
Fanlang’s /.../ literals are PCRE-style regexes (Perl 5-compatible),
not Raku regexes. The Raku adverb :P5 is effectively always in effect.
Whitespace inside /.../ is not significant unless the :s (sigspace) adverb
is set. Standard PCRE inline flags work ((?i) for case-insensitive, (?s)
for dot-matches-newline, etc.), as do lookaround assertions.
if $line ~~ /^\d+$/ { ... }
my $rx = rx:i / hello /; # case-insensitive
my $rx = rx:s / foo bar /; # whitespace significant
my $rx = rx:i:s / Foo Bar /;
$str ~~ s/old/new/; # in-place substitution
$str ~~ s:g/ab/XY/; # global
$str ~~ s/(\w+)/<{$0}>/; # use capture in replacement
$str ~~ s/pat/?{ compute($/) }/; # code in replacement
After a successful match, $/ is the Match object:
$/.from,$/.to— byte offsets$/.Str— the matched substring$/.Bool— success flag$/.gist— debug representation$[0],$[1], … — captures$0,$1, … — same captures, usable in string interpolation
On failure, $/.Bool is false and $/.Str is Nil.
The $! special variable holds the most recent error message from a
builtin that can fail. It is writable — assigning to it is allowed
(for example to clear a prior error: $! = Nil).
The test-regex($pattern) builtin validates a pattern string; it returns
True or Nil (with the error in $!).
split accepts either a regex or a string separator:
my @parts = split(/,\s*/, "a, b, c");
my @lim = split(",", $s, 3); # max 3 splits
my @chars = split("", $s); # per-character
Grammars and Actions
Fanlang provides its own grammar-rule language in the recursive-descent tradition. It is not the Raku grammar language — see the separate Grammar Rules section below for full syntax.
Invoking a grammar
my $ast = MyGrammar.parse($input, $actions);
my $ast = MyGrammar.parse($input, $actions, $starting-line);
- Argument 1 — the input string. Must not be
Nil; aNilvalue raises an assertion error at the top ofparse(). - Argument 2 — the actions object (optional).
- Argument 3 — the initial line number (optional; defaults to
1). Useful whenparse()is re-entered on embedded sub-inputs that originate from a different location in the enclosing source file.
A typical grammar:
grammar Expr {
TOP: sum
sum: product (/[+-]/ product)(s?)
product: atom (/[*\/]/ atom)(s?)
atom: /(\d+)/ | '(' - sum - ')'
}
class ExprActions is Actions {
method TOP ($sum) { $sum }
method sum ($p, @tail) { ... } # @tail is the quantified group
method atom ($n) { $n // 0 }
}
my $ast = Expr.parse("1 + 2 * 3", ExprActions.new);
die $! if !defined $ast;
Grammar.parse(input, actions) returns the action method’s value on success,
or Nil (with the error message in $!) on failure.
The Actions base class provides self.get-line() to recover the current line
number in the input being parsed.
Set FANLANG_DEBUG=1 to enable grammar engine tracing during a run.
Grammar Rules
Summary of the syntax:
Declaration
grammar Name { rule1: ... }
unit grammar Name; # file-level form
rule1: production | alt2 | alt3
Atoms inside a rule body:
| Form | Meaning |
|---|---|
foo | call named subrule foo |
.foo | non-capturing subrule (value not passed to action method) |
'abc' or "abc" | exact string match |
/regex/ | embedded Perl 5 regex; capture groups become arguments |
( ... ) | grouping |
<actionMethod> | code subrule — call a method on the actions object |
Whitespace tokens
| Token | Meaning |
|---|---|
- | optional whitespace (zero or more) |
+ | required whitespace (one or more) |
Override with a user-defined ws rule in the grammar.
Quantifiers — attached to a subrule with parentheses:
| Form | Meaning |
|---|---|
foo(?) | zero or one |
foo(s?) | zero or more |
foo(s) | one or more |
foo(N) | exactly N |
foo(M..N) | M to N |
foo(M..) | M or more |
foo(s) % sep | one or more separated by sep |
foo(s) %% sep | same but trailing sep permitted |
sep can be a named subrule, a string literal, or a regex.
Action methods receive arguments in rule order:
- Named subrules pass their action return value
- Regex captures pass each
(...)group as a separate argument - Quantified subrules pass an array
- Optional subrules (
(?)) passNilwhen absent - Non-capturing
.fooand bracketed groups do not contribute arguments
If an action method returns nothing, the rule’s semantic value is True on
success.
Deviations from Raku
Fanlang intentionally does not implement some Raku features and differs in others. The notable items:
Never implemented
multisubs andmultimethods- Native Raku regexes (we use Perl 5 regexes plus fanlang’s own rule syntax)
Seq
Fanlang has no Seq class. Where Rakudo would return a Seq, fanlang returns
a List.
Regex dialect
/.../ is a Perl 5 regex. Whitespace is insignificant unless :s is set.
There is no native Raku regex grammar.
Grammars
The grammar-rule language is a recursive-descent-style syntax, not Raku’s grammar facility. See Grammar Rules.
Error reporting
Several builtins do not throw on failure; they return Nil and set $!:
slurp($path)Grammar.parse(...)test-regex($pattern)
Gist formatting
.gist of True, False, and Nil is lower-case: true, false, nil.
@*ARGS
@*ARGS is only available in the top-level program. It must not be referenced
from any file loaded via use.
Method syntax
- Private methods are declared as
method !name ($a) { ... }and called asself!name(args)— the bang is part of the invocation.
Nil assignment on arrays
Assigning Nil to an array element may shrink the array if all trailing
elements are Nil.
Builtins
I/O and process
| Builtin | Behavior |
|---|---|
say(...) / .say | print with trailing newline |
print(...) / .print | print without newline |
printf(fmt, ...) / sprintf(fmt, ...) | printf-style formatting: %d %i %u %o %x %X %c %e %E %f %g %G with width/precision |
slurp($path) | read whole file; returns Nil + sets $! on error |
file-exists($path) | returns True if the path exists, False otherwise |
open($path, :r|:w|:b|:bin) | returns an IO::Handle; adverbs are colon-style flags |
system($cmd) / system($cmd, @args) | run subprocess; multi-arg form avoids the shell; returns exit code |
arch-name() | returns the current CPU architecture — one of "arm64", "x64", "x86" |
die($msg) | fatal error with backtrace; bare die uses "Died" |
warn($msg) | warning to STDERR; bare warn uses a default message |
exit($code) / exit | terminate process; bare exit is equivalent to exit(0) |
Strings
.chomp/chomp($s)— strip one trailing newline (\n,\r, or\r\n).chop/chop($s)— drop last character.trim/trim($s)— strip leading and trailing whitespacetrim-leading($s),trim-trailing($s)substr($s, $start),substr($s, $start, $length)—*-nforms supportedsplit(sep, $s),split(sep, $s, $max)index($s, $substr)/index($s, $substr, $from)— returns the 0-based position, orNilif not foundflip($s)— returns$swith characters in reverse orderencode-json(value)— JSON-encode a structure. Preserves non-ASCII bytes as UTF-8 in the output. The OpenResty sentinelngx.null(from ause :luaimport) is emitted as JSONnull.
Time
time— integer Unix secondsnow— floating-point seconds
Collections
| Function form | Method form | Notes |
|---|---|---|
elems($x) | $x.elems | count of elements |
keys(%h) / keys(@a) | .keys | keys / indices |
values(%h) / values(@a) | .values | values |
kv(%h) / kv(@a) | .kv | alternating key/value pairs |
flat(@a) | .flat | flatten one level |
list($x) | .list | convert to a List |
map({ ... }, @a) | @a.map({ ... }) | block sees $_ |
grep({ ... }, @a) | @a.grep({ ... }) | block sees $_ |
first({ ... }, @a) | @a.first({ ... }) | returns first match or Nil |
sort(@a) / sort({...}, @a) | @a.sort({...}) | use $^a, $^b in the comparator |
reverse(@a) | @a.reverse | reversed array / list |
Assigning Nil to a hash key removes the key.
Mutating methods on a typed array — push, unshift, etc. — enforce the
declared element type at run time:
my Str @a = ("foo");
push @a, 1; # error: expecting (Str) but got (Int)
Type testing
defined($x)/$x.defined— notNilis-primitive($x)—Truefor booleans, numbers, strings, andNil;Falsefor arrays, hashes, and objectstest-regex($s)—Trueif$sis a valid regex in fanlang’s regex dialect; elseNiland$!holds the error
I/O Handles
open($path, adverbs) returns an IO::Handle. Adverbs are colon-style
flags: :r (read), :w (write), :b or :bin (binary mode).
my $f = open("/tmp/out.log", :w);
$f.write("line 1\n");
$f.close;
my $g = open "/tmp/out.log", :r;
while True {
my $line = $g.readln;
last if !$line;
print $line;
}
Method summary:
.write(...)— write arguments as text.readln— read the next line (keeps the trailing newline if any); returnsNilat EOF and sets$!on error.close— close the handle
Two global IO handles exist:
$*IN— standard input;.readlnetc.$*OUT— standard output (whatsayandprintwrite to)
Both globals are assignable, and can be my-shadowed within a block to
redirect output for that scope:
if True {
my $*OUT = open("/tmp/log", :w);
say "this line goes to the file"; # uses the shadowed $*OUT
}
say "this line goes to stdout"; # original $*OUT restored
Assigning to $! is allowed, e.g. to clear a prior error.
Preprocessor Macros
Fanlang supports C-style conditional compilation directives, driven by the
-D NAME[=VAL] CLI flag (repeatable):
#ifdef FOO
say "FOO was defined";
#elifdef BAR
say "BAR was defined";
#else
say "neither";
#endif
#ifndef DEBUG
say "release build";
#endif
Available directives: #ifdef, #ifndef, #elifdef, #elifndef,
#else, #endif.
Environment Variables
| Variable | Effect |
|---|---|
FANLANG_DEBUG=1 | enable grammar engine tracing |
FANLANG_TIMING | emit timing measurements |
FANLANG_CLIENT=1 | route fan invocations to the local compile server |
FANLANG_SERVER_PORT=N | port for the compile server (default 5000) |
Server / Client Mode
To skip compiler startup cost on repeated invocations, fanlang can run as a
local HTTP compile server. With the server running, set FANLANG_CLIENT=1
(and optionally FANLANG_SERVER_PORT) and continue to use fan as usual;
invocations go over HTTP to the server.
Copyright
Copyright (C) 2016–2026 OpenResty Inc. All rights reserved. This software is proprietary and must not be redistributed or shared.