Language syntax#
Source files end in .mars. Statements end with ;; blocks use {...}.
// starts a line comment; /* ... */ encloses a block comment.
Variables and types#
func m{
count (int) = 1;
count = count + 1;
if (true){
cold count = 10;
out(count);
}
out(count);
}
Bare assignment updates the nearest existing binding, or creates a local if none
exists. A type annotation or cold explicitly declares a local and can shadow an
outer binding. Duplicate declarations in the same scope are errors.
| Form | Meaning |
|---|---|
name = value; |
Assign an existing binding or create a local |
name (type) = value; |
Declare a typed local |
cold name = value; |
Explicitly declare a local |
fixed name = value; |
Prevent reassignment; recursively freeze container values |
hot name = constant; |
Substitute a constant expression; reassignment prohibited |
Place fixed before hot or cold when combining modifiers. The keyword is
fixed, not final. Direct initialization from a fixed binding inherits its
fixed status. .copy() creates a mutable deep copy of a container.
Supported runtime types include int (signed 32-bit), longint (signed 64-bit),
float, bigint (a whole number of any size, std.bigint),
string, array, set, pair, map/dict, the calendar kinds
date, datetime, and duration (dates and times), and task
(async). any accepts every
value, for parameters that work with anything, such as func push(any item). Collection restrictions
use forms such as array[int], pair[string,int], and map[string,int].
This is runtime checking, not a complete static type system.
Marslang is dynamically typed. An unannotated variable can change from an integer
to a string or another kind of value on reassignment. An annotation restricts the
binding at runtime; fixed prevents reassignment regardless of type. Plain =
already provides automatic type detection; := remains excluded.
Numeric values retain their kind through calls, returns, fields, and containers.
1.0, 1e0, and float(1) remain floats even though their value is integral.
Numeric unions prefer the actual kind instead of converting a float to the first
integer alternative. Numeric annotation/conversion boundaries retain their checked
conversion behavior: for example, assigning 1 to a float binding yields a float,
and to a bigint binding a bigint. Conversions only widen: a bigint passed to an
int, longint, or float parameter is a TypeError until it is converted with
int(...), longint(...), or float(...).
For exact matching between arguments, see std.math.
Arithmetic follows the runtime operand kinds. An operation involving a float
produces a float; non-integral number division also produces a float. Integer
arithmetic checks overflow, including through union-typed parameters: an int or
longint result that does not fit raises RangeError and never becomes a bigint.
An operation involving a bigint and an int or longint produces a bigint, exactly.
Mixed longint/float and bigint/float arithmetic require an explicit conversion.
Equality and ordering compare numbers by exact value across all kinds: 1 == 1.0,
1 == longint(1), bigint(1) == 1.0, and longint(5) < 10 are all true, and equal
numbers are the same set element or map key. Strings are never coerced to numbers
for equality.
Functions and families#
func add(int a, int b) => a + b;
family Counter{
func init(int value){ me.value = value; }
func next{
me.value = me.value + 1;
ret me.value;
}
}
func m{
counter = Counter(4);
out(add(counter.next(),2));
}
Parameters use type name, separated by commas with no trailing semicolon.
No-argument functions may omit (). m is the entry function and takes no
parameters. Use ret value; to return from a block function. Expression functions
use => expression;.
family Child(Parent){...} declares inheritance. init is the constructor, and
me refers to the current instance. Call a family name to create an instance.
A family name used as a type, such as func area(Shape s) or array[Shape],
accepts instances of that family and of every family that inherits from it. The name
refers to the family declared in the same file; use alias.Family for a family from
an imported package. Families with the same name in different packages are different types.
Only slicing accepts a named reverse= argument; other calls are positional.
Functions as values#
A function is a value: pass it, store it, and call it later. func(type name) => expression makes one without a name, and a func written inside a block is a
named one that only that block can see.
func apply(Function f, int value) => f(value);
func counter(){
count = 0;
func bump(){
count = count + 1;
ret count;
}
ret bump;
}
func m{
double = func(int x) => x * 2;
out(apply(double, 5), apply(func(int x) => x + 100, 1)); // 10 101
next = counter();
next();
out(next()); // 2
}
| Form | Meaning |
|---|---|
func(int x) => x * 2 |
An anonymous function; its body is one expression |
func name(...){ ... } inside a block |
A named function local to that block, which can call itself |
Function f |
A parameter that takes any function, bound method, or built-in |
Family F |
A parameter that takes a family, to construct with F(...) |
An anonymous function's body is a single expression. For more, declare a named
func in the block and pass its name: func(int x){ ... } is an error that
says so.
Closures. A function made inside another keeps the variables around it,
not copies of their values: assigning to one inside the function changes it
outside, and a change outside is seen inside. Each call of counter above makes
a new count, which its bump keeps after counter returns. Inside a method,
a nested function keeps me and may call the family's private methods.
Because the variable itself is kept, functions made in a loop share the loop variable and see its last value:
func m{
fns = arr();
for (i = 0, i < 3, i++){ fns.add(func(int x) => x + i); }
out(fns[0](10)); // 13, since i is 3 once the loop ends
}
To keep each value, make the function in a function that takes it as a parameter, which is a new variable on every call.
Function and Family are parameter types only; f (Function) = ... is a
compile error. A nested function cannot be reassigned, and closures that
refer to each other or to themselves are reclaimed like any other cycle.
Tasks and await#
An async func runs as a task: calling it checks the arguments and returns
a task at once, and the body runs once the code running now waits.
await task waits for it to finish and gives its result, or raises its error.
takepkg std.async;
async func later(string text, float seconds){
await async.sleep(seconds);
ret text;
}
func m{
first = later("first", 0.02);
second = later("second", 0.01);
out(await first + " " + await second); // first second
}
Both tasks wait at the same time, so m waits 0.02 seconds, not 0.03.
await works in any function, async or not, and in m; it binds tighter than
any operator, so await a + await b adds two results, and it raises
TypeError for a value that is not a task. A task has done(), result(),
and cancel(); tasks still running when m returns finish before the
program ends. The whole story is in async, and timers and
waiting for several tasks in std.async.
Control flow#
| Form | Behavior |
|---|---|
if (condition){...} elif (condition){...} else {...} |
Conditional branches |
while (condition){...} |
Repeat while truthy |
repeat count {...} |
Evaluate count once; repeat a nonnegative integer number of times |
for (item,iterable){...} |
Visit each item, or each map key |
for (i=0,i<10,i++){...} |
Initialization, condition, step; separated by commas |
break; exits the nearest loop. continue; skips to its next iteration; the step
still runs in a three-part for. Multiple initialization/step statements can be
grouped using also, for example for ((i=0 also j=0),i<3,(i++ also j++)){...}.
False values are false, null, and numeric zero. Empty strings and containers
are truthy. fasle is an accepted alias of false.
inf is not a keyword. Use float("inf"), float("-inf"), or float("nan")
for non-finite float values; see math classification.
match#
match compares one value with a list of patterns and runs the first arm that
fits: a clean replacement for a long if/elif chain.
family Shape{}
family Circle(Shape){
func init(float r){ me.r = r; }
func width() => me.r * 2;
}
func describe(any value){
match (value){
1 => ret "one";
2, 3 => ret "two or three";
"quit" => { out("cleaning up"); ret "bye"; }
int n if (n > 100) => ret f"big {n}";
string s => ret "text " + s;
Circle c => ret f"a circle {c.width()} wide";
Shape => ret "some other shape";
null => ret "nothing";
_ => ret "anything else";
}
}
func m{
out(describe(1)); // one
out(describe(3.0)); // two or three
out(describe(500)); // big 500
out(describe("hi")); // text hi
out(describe(Circle(1.5))); // a circle 3 wide
out(describe(Shape())); // some other shape
out(describe(null)); // nothing
out(describe(7)); // anything else
}
The value in parentheses, the subject, is evaluated once. The arms are tried from top to bottom, and the first whose pattern matches, and whose guard holds if it has one, runs; there is no fall-through to the next arm. When no arm matches, nothing happens.
An arm is pattern => body. The body is one statement, ending in ;, or a
{ ... } block, and starts on the line of its =>. ret, break, continue,
and errors behave as in an if block: ret returns from the function, and
break leaves the loop around the match. The last arm may leave out its ;
before the closing brace.
Patterns#
| Pattern | Matches |
|---|---|
1, -2.5, "quit", true, null |
A value equal to the literal, as == says: 1 matches 1, 1.0, longint(1), and bigint(1), but not "1" |
2, 3, 5 |
A value equal to any of the literals |
_ |
Any value |
int n |
A value of that type, bound to n for the arm |
string |
A value of that type, without a name |
[int, float] n |
A value of any of the listed types |
Circle c |
An instance of the family Circle, or of a family inheriting from it |
geo.Circle c |
The same, for a family from the imported package geo |
pattern if (condition) |
What the pattern matches, when the condition is also true |
The types are int, longint, bigint, float, string, bool, array,
set, map (or dict), pair, date, datetime, duration, any, and
families, including the error families. Literals are numbers, strings, true,
false, and null. A variable is not a pattern; compare with one in a guard:
_ if (value == limit) => ....
A type pattern converts nothing. An annotation turns 1 into 1.0 for a
float parameter, but float f matches only floats: it does not match 1, and
int n does not match 1.0 or longint(1). Write [int, float] n to accept
both. Families work as in annotations: Shape s matches instances of Shape
and of every family inheriting from it. any matches every value, null
included. Patterns do not check elements, so there is no array[int] a; write
array a and check the elements in a guard or in the body.
Guards and names#
func m{
k = "outer";
for (n, arr(-3, 4, 0)){
match (n){
int k if (k < 0) => out(f"negative {k}"); // negative -3
int k if (k % 2 == 1) => out(f"odd {k}");
int k if (k > 0) => out(f"positive {k}"); // positive 4
_ => out("zero"); // zero
}
}
out(k); // outer
}
A guard, if (condition) after any pattern, is checked only when the pattern
matches; when it is false, the next arm is tried. A type pattern binds its name
before the guard runs, so the guard and the body can both use it.
The name belongs to its arm, as the item of for (item, ...) and the error of
handle(Type e) belong to theirs: it is a new variable that hides an outer
variable of the same name inside the arm only, and it is unknown after the arm.
Assigning to it in the arm changes the arm's variable, never the outer one.
match as a value#
match also makes a value. Each arm's body is then an expression, and the
result is the value of the arm that matches.
func label(int n) => match (n){
1 => "one";
2, 3 => "a few";
int k if (k > 100) => "lots";
_ => "some"
};
func m{
out(label(2), label(500), label(7)); // a few lots some
word = match (label(1)){ "one" => "uno"; _ => "?" };
out(word); // uno
out(match (word){ "uno" => 1; _ => 0 } + 1); // 2
}
An expression match with no matching arm has no value to give, so it raises
RangeError: no match arm matches 7 (an int); end with a _ arm when every
value needs an answer. It may span several lines after =, ret, a
function's =>, or another arm's =>. Inside parentheses, as in a call's
argument, it must fit on one line, since a { there cannot follow a line
break; give it a variable first when it is longer.
Errors#
These are compile errors, and each names its line:
| Written | Error |
|---|---|
match (x){ } |
The match has no arms |
1 out("one"); |
An arm needs => |
_ => ...; before another arm |
The arms after it can never run; so does any v without a guard |
1 => ...; 2, 1.0 => ...; |
1 appears twice; a repeat after an arm with a guard is allowed |
Cirle c => ...; |
Unknown type |
limit => ...; |
Unknown type: a bare name is a type, so compare with a variable in a guard |
array[int] a => ...; |
Patterns do not check element types |
int, float => ...; |
Only literals are listed with commas; write [int, float] |
At run time, an error in an arm's guard or body is reported on the arm's own
line, and an expression match without a matching arm raises RangeError on
the line of the statement it is in.
match is a keyword. It cannot name a variable, parameter, function, family,
loop variable, or handled error, so a program that used it as a name must
rename it (the standard library never did). After a dot it is still an
ordinary name, so a field such as result.match keeps working.
How it works#
The parser reads each arm into a pattern, an optional guard, and a body, and
the resolver gives a type pattern's name a scope of its own, as it does for the
item of a for. Literal patterns become values when the program is compiled,
so matching one compares the subject with it directly; the if/elif chain a
match replaces evaluates its variable, and builds each string literal, again
for every comparison, so a match over literals is never slower and usually
faster. A type pattern checks the value's kind, walking the family chain for a
family. All of it is in the Rust interpreter (src/parser.rs,
src/resolve.rs, and src/interp.rs).
Private methods#
takepkg std.Decorator;
family Account{
func init(){ me.balance = 0; }
@Decorator.private
func _audit(string action){ out("audit " + action); }
@Decorator.subclass
func _limit() => 100;
func deposit(int amount){
me._audit("deposit"); // allowed: a method of Account
me.balance = me.balance + amount;
}
}
family Savings(Account){
func limit() => me._limit(); // allowed: Savings inherits from Account
}
func m{
Account()._audit("x"); // TypeError: _audit is private to Account
}
Decorators are written on the line above a family method (or before func on the
same line) and need takepkg std.Decorator;; with takepkg std.Decorator = D;
write @D.private.
| Decorator | Who can call the method |
|---|---|
@Decorator.private |
Only methods of the family that declares it; not inheriting families |
@Decorator.subclass |
Methods of the declaring family and of every family inheriting from it |
Anything else, including top-level code and free functions, gets a TypeError,
both when calling the method and when taking it as a value (f = obj._audit;).
A method taken as a value inside its family can be stored or passed on, but every
call is checked against the method running at that moment, so it stays private.
Methods without a decorator are public. Fields are always public; the _name
convention marks fields meant for internal use. init cannot be private.
Error handling#
family ParseError(Error){}
func parse(string text){
if (text == ""){ err(ParseError, "empty input"); }
ret text;
}
func m{
run{
parse("");
} handle(ParseError e){
out(e.message); // empty input
} handle(TypeError, RangeError){
out("a built-in error");
} then{
out("always runs");
}
}
Errors are families. Error is the base family, and TypeError, RangeError,
OutOfBoundsError, SyntaxError, and CancelledError (raised in a cancelled
task) inherit from it. Declare your own with
family Name(Error){} or inherit from a built-in kind. Every error has a message
field and prints as Name: message.
std.Error names the built-in families in one namespace:
takepkg std.Error; then err(Error.RangeError, "too big"),
handle(Error.TypeError e), and family ParseError(Error.Base){}. They are the
same families as the bare names. A family is raised with err, never called:
TypeError("x") is itself a TypeError that says so.
| Form | Meaning |
|---|---|
err(Family, message) |
Raise a new error of that family |
err(e) |
Raise a caught error again |
handle(Type e){...} |
Catch Type or any family inheriting from it, bound to e |
handle([Type1, Type2] e){...} |
Catch any listed family, bound to e |
handle(Type1, Type2){...} |
Catch any listed family without a name |
then{...} |
Optional cleanup |
lasterr() |
The most recently handled error, or null |
run needs at least one handle or a then. Handlers are checked top to bottom
and the first match runs; unmatched errors continue outward. then always runs
last: after the body, after a handler, while an unhandled error propagates, and when
the block exits early with ret, break, or continue. An error, ret, break,
or continue inside then replaces the pending outcome. Errors raised by the
interpreter itself, such as division by zero, are caught the same way. Use
alias.Family to name an error family from a package, in a handler, in err,
or as a parent: family Timeout(net.NetworkError){}.
Operators#
Arithmetic: +, -, *, /, %, **; unary + and -.
Comparisons: ==, !=, <, <=, >, >=.
Boolean operators: not, and, or. and/or short-circuit and return booleans.
await waits for a task and binds tighter than any of these.
Updates include =, +=, -=, ++, and --.
Equality does not coerce strings into numbers. Containers compare by identity.
String + joins two strings. Typed integer overflow raises an error.
Exponentiation is right-associative; arithmetic precedes comparisons, which precede
not, then and, then or.
Packages#
takepkg name; imports a package and binds it to the last segment of its name;
takepkg name = alias; chooses the binding. Members are read with alias.member
and are read-only.
Packages work much like Python's. Absolute names resolve from the program's root
directory (the main file's directory); a directory containing init.mars is a
package, and dotted names map to subdirectories. A name the program itself does not
provide is looked for next in your package directory, so packages you install are
importable from every program you write.
| Form | Loads |
|---|---|
takepkg std.math; |
A standard package, written in Marslang and built into marslang |
takepkg util; |
util/init.mars if util/ is a package, otherwise util.mars |
takepkg shapes.circle; |
shapes/circle/init.mars or shapes/circle.mars |
takepkg .vec; |
vec in the current package (a sibling module) |
takepkg ..helpers; |
helpers in the parent package; each extra dot goes up one level |
A module file belongs to the package of its directory, and an init.mars file
is its directory's package. Relative imports need a parent package: the main program
and top-level files use absolute names, and going above the top-level package is an
error. Importing a.b.c first runs a/init.mars and a/b/init.mars when
they exist. A package may import its own modules from init.mars. The name init
is reserved for these files, so no module can be named init.
Your package directory#
Packages installed for you live in marslang_pkgs in your home directory
(C:\Users\you\marslang_pkgs, /home/you/marslang_pkgs), or wherever
MARSLANG_PKGS points. marslang pkgs prints the directory it will use.
The program's own directory is searched first, so a file beside your program
always wins over an installed package of the same name, and a program cannot be
changed by something installed later. Within one package the two never mix: a
package found in your package directory runs the init.mars files of its parents
from there as well. When an import fails, the error lists every file that was
looked for, in both places.
Each package is loaded once under its absolute name, however it is written or however
many files import it; its top-level statements run once, before the importing file's.
A package exports its functions, families, and fixed/hot top-level bindings.
Names that start with _ are private to the package, and so is a function or
family marked @Decorator.private. A package's m function is not
run. Circular imports are rejected. takepkg package = *; is planned and currently rejected.
Standard packages are written in Marslang under std/. The few primitives Marslang
cannot express (platform float functions, raising a named error, reading a value's
kind) come from native packages written in Rust under std/rs/, imported as
takepkg rs.NAME;. Only standard packages may import rs.* packages.
@Decorator.static, @Decorator.class, and @Decorator.overload
are pending. := and native bitwise
operators are excluded. Proposed syntax is not an implemented API.