The Lynxer language
This page describes the Lynxer language as the interpreter actually parses and runs it, with every example verified against the interpreter. For the behaviour that is deliberately constrained or not implemented, see limitations.
Related pages: functions, types, lists, structs, classes, enums, vargroups, modules, and builtins.
A first program
global setup() {
println("ready");
}
global main() {
println("Hello, Lynxer!");
}
Requires the interpreter (see install) and runs with lynxer hello.lynx. If setup or main is missing, the parser reports it with a source location, for example lynxer: hello.lynx:2:2: program must define global setup().
Program structure
A file is a sequence of top-level declarations, in this order:
global setup()— must be the first declaration. The interpreter raisesglobal setup() must be the first declarationotherwise.- Anything else — file-level
funcs,globalfunctions,struct,classandenumdefinitions beforemain. A class's parent must be declared before the child class. global main()— the default entry point, and the last declaration. A declaration aftermainfails withdeclarations may not follow global main().
Executable code outside a function body is a syntax error (expected top-level function declaration).
setup() and main() may take defaulted parameters; they are filled in before the body runs:
global setup(int retries = 3) {
println("retries ", retries); // retries 3
}
global main() {
println("main");
}
overrideMain
When setup() calls overrideMain("name"), the named global function runs instead of main, and main becomes optional:
global setup() {
overrideMain("start");
}
global start() {
println("started"); // printed
}
global main() {
println("unused");
}
Comments and docstrings
// a single-line comment
///
a multi-line delimiter comment;
the closing delimiter is the same slash run
///
////
a file docstring. `--list-stdlibs` prints this block for a module.
////
//runs to end of line./// ... ///and//// ... ////are multi-line blocks opened and closed by the same slash run./* ... */is not a comment — it fails to parse (expected a declaration, assignment, or print call).- The docstring form recognised by
--list-stdlibsis a line that is exactly////; a module that opens with//// texton the same line will not have its docstring extracted.
Comment markers inside string literals are ordinary text.
Variables and declarations
global setup() {
int count = 0;
const str APP = "demo";
println(APP, " ", count); // demo 0
}
global main() {
float ratio = 1.5;
ratio += 0.5;
println(ratio); // 2
}
- A declaration is
type name = value;. constmakes later assignment a runtime error:variable 'frozen' is constant and cannot be reassigned.- Fixed-width types reject out-of-range values at the declaration, parameter, assignment, and return boundaries (see types).
- A declaration inside a loop or
ifbody belongs to the enclosing top-level scope, not a nested one (see Scoping).
Ownership and borrowing
Variables own their value. A few built-ins make moves and temporary aliases explicit, which matters when a value is expensive to copy.
int source = 42;
int destination = 0;
varTransfer(source, destination); // source is moved; destination owns 42
int borrower = 0;
varBorrow(destination, borrower); // borrower is a read-only alias
println(borrowing(borrower)); // true
println(beingBorrowed(destination)); // true
varEndBorrow(borrower); // borrower becomes an independent copy
varTransfer(source, destination)moves the value into an already-declared destination whose declared type must accept it. A moved source cannot be read, passed to a function, borrowed or moved again; a plain assignment reinitialises it. Transfers are rejected while either side has an active borrow, and constants cannot be moved.varTransferMutate(source, destination)is the move form for ananyornumdestination: it accepts a value of any runtime type, wherevarTransferrequires the declared type to match.varBorrow(source, borrower)makes a read-only tracked alias. The source stays readable and several read-only borrowers may coexist, but neither side may be written while the borrow is active.varEndBorrow(borrower)ends it and gives the borrower an independent copy of the source's current value. Borrowing a moved value, borrowing into a constant, and ending a borrow that is not active are errors.borrowing(variable)reports whether a variable is currently a borrower, andbeingBorrowed(variable)whether another variable is borrowing from it. Both accept moved variables, so ownership state can be inspected while handling an error.varBorrowMutate(source, borrower)makes an exclusive mutable borrow: the borrower aliases the same storage, so writes through either name are visible through the other. Another active borrow makes it fail, and ending it withvarEndBorrowleaves the borrower independent.varSwapAll(first, second)exchanges values and declared type metadata;varSwapVal(first, second)exchanges only values and keeps each declared type, so both cross-assignments must be type-compatible. Both require independent, live, mutable variables and validate everything before changing either one.
Shared variables
Prefix a typed declaration with shared to make it a mutable alias of an existing variable. Assignments through either name update the same storage:
int x = 42;
shared int y = x;
y = 100; // x is now 100 too
x = 7; // y observes the same value
unshare(y); // y becomes independent, keeping its current value
y = 200; // x stays 7, y is 200
The initializer must be a variable name, the declared type must accept the source's value, and the source must be readable (not moved). unshare(name) detaches the alias; the detached variable keeps its current value and declared type. shared is a declaration form of varBorrowMutate, and unshare is an alias of varEndBorrow — so a shared variable reports borrowing(alias) and beingBorrowed(source) as true until it is detached.
The arguments to these built-ins are variable names, not values. A runnable example is lynxer/examples/ownership.lynx.
Types
The complete type list, ranges, and conversion rules live in types. The scalar names are int, float, num, bool, str, char, numBool, bit, byte, int8/int16/int32/int64, uint8/uint16/uint32/uint64, float32/float64, plus any, none, list, tuple, codeblock, and the named struct/class/enum types.
Operators
Lynxer uses keyword operators: is / isnt, and / or / not, bitand / bitor / bitxor, bitleft / bitright, and so on. The symbolic C-style spellings (==, !=, &&, ||, &, |, ^, <<, >>, …) still parse but are deprecated and print a warning; a bare ! is a syntax error.
int n = 6;
if((n % 2 is 0) and n > 4){
println("even and greater than four");
}
println(n bitand 3); // 2
→ Full operator reference, precedence and the deprecation table
Control flow
- conditionals —
if/elif/elseandswitch/case/default. - loops —
while,for(both the C style and the range form),doWhile,iterate,forever, andbreak/continue/restart. - try-catch —
try/catch,raise, andexceptionInfo().
try / catch
try {
println(1 /% 0);
} catch (str error) {
println("caught ", error); // caught division by zero
}
try handles runtime errors. The binding clause is optional; the caught value is the message string. raise(message) raises one, and exceptionInfo() returns the traceback inside a handler.
→ Full try / catch reference: raise, exceptionInfo(), nesting, and what is not caught
return
return expr; returns a value; a bare return; returns nothing. The value is checked against the function's return annotation, if any.
Functions
See functions for a full page on each form. There are three function declaration forms and a scoped named lambda form:
| Form | Written | Where | Called from |
|---|---|---|---|
| Global function | global name(...) | between setup and main | global.name(...) or a bare name in the same file |
| File-level function | func name(...) | between setup and main | a bare name in the same file; global.module.name(...) from an importer |
| Local function | local name(...) | inside another function body | a bare name, or local.name(...) |
| Named lambda | lambda name(...) | inside a function body | its name in the declaring scope |
global func name(...) is not valid syntax; use func name(...) for a file-level function.
func double(int n) -> int {
return n * 2;
}
global add(int a, int b = 1) -> int {
return a + b;
}
global main() {
local square(int n) -> int {
return n * n;
}
println(double(4)); // 8
println(global.add(2)); // 3
println(square(3)); // 9
}
Named lambdas are local to their declaring function scope and are not exported; nested function scopes cannot see them. See functions for an example. Functions and lambdas can also be stored as function values, returned, and passed as arguments; see function values.
Parameters and defaults
- Parameters may be typed (
int n) or untyped (treated asany). - A parameter may have a default (
int b = 1). - A required parameter may not follow one with a default:
required parameters cannot follow a parameter with a default value. type T of valuedeclares an inferred type parameter for an ordinary function. ReuseTin later parameter types, local declarations, or the return annotation; each call infersTfromvalue. See functions for an example and restrictions.- Class methods are the exception: their parameters are typed but may not have defaults (see classes).
Return annotations
Only the arrow form is accepted:
global f(int x) -> int { return x + 1; }
: intis not valid syntax (unexpected character ':').- Omit the annotation, or use
-> any, to leave the result unchecked. -> noneis accepted: the function must return nothing, and a returned value fails withvalue cannot be assigned to type 'none'.- A mismatched return is a source-located runtime error.
- Named types (a
struct,class, orenum) may be used as the annotation.
Codeblocks
A function may declare caller-supplied blocks after its parameters and invoke them with exec(). See codeblocks for the full reference.
Scoping
Lynxer has no nested block scopes, and blocks do not shadow:
setup()andmain()share one top-level scope. A variable declared in either — including inside anif,while,for,switch, ortrybody — is visible in the other and to any function called later.- Declaring the same name again overwrites the previous value rather than shadowing it.
- A function or method call gets a fresh scope for its parameters and locals. Those do not leak out, but the function can read and update the top-level variables it closes over.
global setup() {
int total = 0;
}
func add(int n) {
total += n; // updates the top-level variable
int scratch = n; // local to add(); not visible outside
}
global main() {
add(2);
add(3);
println(total); // 5
}
String interpolation
inter"..." interpolates {expr} inside a string, with \{, \}, and \\ as escapes:
global main() {
int n = 3;
str message = inter"n is {n}";
println(message); // n is 3
println(inter"also {n}"); // also 3
}
inter"..." is a string expression, so it can be used wherever a string value is accepted, including output calls, assignments, and return values:
global describe(int n) -> str {
return inter"value: {n}\n";
}
The embedded expressions run when the string is evaluated. Each result is converted to text and inserted between the literal parts.
Modules
import("name") and importAs("name", "alias") load .lynx source modules or native .so libraries. They are statements, so they run from any function body — most often setup() — not at file scope. Both arguments must be string literals. See modules.
Aggregate types
- structs — data-only records built with
new. - classes — fields plus
localmethods. - enums — tagged unions with payloads and
switchpatterns. - vargroups — typed records with defaults and dot access.
- lists —
listandtuplevalues and their builtins.
Not supported
See limitations for the complete list. The most common surprises are \x/\u escapes, the .lynxc bytecode format, and the not-planned modules (tkinter, tkinterPlus, turtle, venv).