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:

  1. Productivity: Perl 6 / Raku is substantially more expressive than Lua, especially for DSL compilers and other text-heavy tooling.
  2. Delivery: we want to ship source code that we did not write by hand, protecting intellectual property while still giving customers runnable code.
  3. 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):

OptionMeaning
-c, --compilecompile only (without execution)
-D NAME[=VALUE]define a macro (repeatable); used by #ifdef / #ifndef. Value is the empty string if omitted
-d, --dump-astdump out the AST
-e CODEexecute the Fan code directly
--echo-backecho back FanLang code from the AST
--gen-modulegenerate a reusable library module in the target language
-h, --helpprint this usage
-I PATHspecify the search path for loading user-defined fan modules (repeatable)
--no-runtime-checkdisable runtime check
-O Nenable optimization with the level N
-o FILEspecify the output file name
--runtime-versionspecify the runtime version
--test-grammartest grammar only
-v, --verbosebe 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 ~32 prints "32"
  • Concatenation (infix): ~
  • Replication: x (string); compound form x=
  • Comparison: == != < <= > >= (numeric), eq ne lt le gt ge (string), === !=== (identity)
  • Three-way numeric comparator: <=> (returns -1, 0, or 1) — pairs naturally with sort { $^a <=> $^b }
  • Logical: && || !
  • Defined-or: // — returns the right operand when the left is Nil (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:

LevelOperatorsNotes
termliteral, variable, self, *, (expr)atoms
postfix.meth, .(...), .[...], .{...}, .<...>, indexing, invocation
prefix! - + ~ ? +^unary
1**exponent
2* / % +& +< +>multiplicative
3`+ - ++^`
4xstring replication
5~concatenation
6&junction (all)
7``
8== != < <= > >= === !=== ~~ !~~ <=> eq ne lt le gt gerelational, 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?)$b is Nil when 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, $^b auto-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 .new has 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 expression
  • self!private(args) — private method (only inside the class)
  • Methods can be declared submethod for initialization-style methods that do not participate in inheritance dispatch

Introspection

  • value.WHAT — type object, prints as (Array), (Hash), (Num), etc.
  • value.isa(Type) or value.isa('TypeName') — type test
  • value.ACCEPTS(other) — smart-match acceptance protocol
  • obj.HOW.name($obj) — class name
  • obj.HOW.attributes($obj) — list of Attribute meta-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; a Nil value raises an assertion error at the top of parse().
  • Argument 2 — the actions object (optional).
  • Argument 3 — the initial line number (optional; defaults to 1). Useful when parse() 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:

FormMeaning
foocall named subrule foo
.foonon-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

TokenMeaning
-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:

FormMeaning
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) % sepone or more separated by sep
foo(s) %% sepsame 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 ((?)) pass Nil when absent
  • Non-capturing .foo and 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

  • multi subs and multi methods
  • 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 as self!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

BuiltinBehavior
say(...) / .sayprint with trailing newline
print(...) / .printprint 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) / exitterminate 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 whitespace
  • trim-leading($s), trim-trailing($s)
  • substr($s, $start), substr($s, $start, $length)*-n forms supported
  • split(sep, $s), split(sep, $s, $max)
  • index($s, $substr) / index($s, $substr, $from) — returns the 0-based position, or Nil if not found
  • flip($s) — returns $s with characters in reverse order
  • encode-json(value) — JSON-encode a structure. Preserves non-ASCII bytes as UTF-8 in the output. The OpenResty sentinel ngx.null (from a use :lua import) is emitted as JSON null.

Time

  • time — integer Unix seconds
  • now — floating-point seconds

Collections

Function formMethod formNotes
elems($x)$x.elemscount of elements
keys(%h) / keys(@a).keyskeys / indices
values(%h) / values(@a).valuesvalues
kv(%h) / kv(@a).kvalternating key/value pairs
flat(@a).flatflatten one level
list($x).listconvert 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.reversereversed 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 — not Nil
  • is-primitive($x)True for booleans, numbers, strings, and Nil; False for arrays, hashes, and objects
  • test-regex($s)True if $s is a valid regex in fanlang’s regex dialect; else Nil and $! 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); returns Nil at EOF and sets $! on error
  • .close — close the handle

Two global IO handles exist:

  • $*IN — standard input; .readln etc.
  • $*OUT — standard output (what say and print write 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

VariableEffect
FANLANG_DEBUG=1enable grammar engine tracing
FANLANG_TIMINGemit timing measurements
FANLANG_CLIENT=1route fan invocations to the local compile server
FANLANG_SERVER_PORT=Nport 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 (C) 2016–2026 OpenResty Inc. All rights reserved. This software is proprietary and must not be redistributed or shared.