Skip to content

The Language

This chapter is where I'm going to go over the different types of statements, operators, and expressions that make up solus, and how to successfully arrange them into a program. Statements will be grouped into related sections by their behavior and idiomatic usage, and I will later explain the difference between statements and expressions in detail. But first, I'll explain the basics.

Operators

Operators are the characters between identifiers (named values) or literals that perform a specific operation on or between the two. The standard arithmetic operators should be extremely familiar to you, as they're the exact same ones often used in standard math, but solus also has a handful of special operators that will be both familiar and unfamiliar to experienced programmers.

Arithmetic

Arithmetic operators are binary operators that perform some kind of math operation between two values. An important thing to note is that arithmetic operators may have prefix and in-place variants, but those are not the same operator and do not always work on the same types.

OperatorDescriptionTypes
a + bAdd / Concatenatei64 f64 str obj
a - bSubtracti64 f64 bool
a * bMultiplyi64 f64
a / bDividei64 f64

Prefix

Prefix operators are unary operators, meaning they perform a modification to a single value rather than between two. It's important to note again that although all prefix operators have in-place variants, prefix operators do not modify the original value but rather evaluate their modification as part of an expression.

OperatorDescriptionTypes
-xNegatei64 f64 bool
!xBang / Notbool

Postfix

Postfix operators come after a value, and in solus postfix operators are only used for one of two things: get operations and member calls. The . operator has special behavior that automates member calls, and this can be bypassed by using []. An example will be used rather than a table because there are only three postfix operators and they are all quite similar.

solus
var x = object.member; # or object['member']
var y = object[0];
var c = function(arg1, arg2); # () qualifies as postfix

Comparison

Comparison operators are another set of math operators, often called equality operators as well. The and/or operators have dedicated keywords, which are in fact the recommended idiomatic option rather than the character alternative, as they behave differently from most languages in that they short circuit to the first available value.

OperatorDescriptionTypes
a == bEqualany
a != bNot equalany
a < bLess thani64 f64
a > bGreater thani64 f64
a <= bLess than or equali64 f64
a >= bGreater than or equali64 f64
a and b a && bLast truthy conditioni64 f64
a or b a || bFirst truthy conditioni64 f64

Truthiness is an important topic to note, and that's whether a value when coerced into a bool should be true or false. Outside of condition contexts, and/or short-circuit and evaluate to the last and first truthy condition respectively. The truthiness conditions of different primitive types is explained in the table below.

PrimitiveTruthiness condition
nilAlways false
f64true unless nan
i64Non-zero values are true
dynAlways true

In-Place

In-place operators perform their operation on the original value, meaning they must be performed on either a variable of an object's member, and cannot be used on literals. There are also three different types of in-place operators, those being prefix, postfix, and equals.

The difference between prefix and postfix operators are the order the operation and evaluation are performed in, prefix operators performing the operation before evaluation, and postfix performing it after. That means that postfix operators evaluate to the original value prior to the performed operation.

PrefixPostfixEqualsUnfoldedTypes
++xx++x += 1x = x + 1i64 f64
--xx--x -= 1x = x - 1i64 f64
x *= 1x = x * 1i64 f64
x /= 1x = x / 1i64 f64
!!xx = !xbool

Note: the name of the !! operator is the toggle operator

Operator Overloading

Using metadata, you can overload most operators in solus with custom functions, allowing you to extend the behavior of dynamic objects to implicit operations. As usual this is done with a metadata entry prefixed with _. Overloaded operations also have another implicit behavior in that if they return a dynamic object, it will be assigned the same metadata as the original object.

solus
{ 0, 0 }.usemeta({
    _add = [](a, b) {
        { a[0] + b[0], a[1] + b[1] }
    }
    _get = [](obj, key, value) {
        if key == 'x'
            obj[0]
        if key == 'y'
            obj[1]
        nil
    }
});
EntryArgsOperator
_adda b+
_suba b-
_mula b*
_diva b/
_eqa b==
_negself- !
_setself key value. []
_getself key. []
_call...()

Variables

There are two types of declarations in solus, variables and named values. All variables are named values, but not all named values are variables. You can think of the keyword var as defining a name that can have values assigned to it, and val as assigning a name to an existing value. Purely named values cannot be modified, because they are simply a name assigned to a local value slot. Variables on the other hand are names whose value may vary.

solus
var x = 2, y = 4, z;
val named = 'hello!';

x = named; # all good
named = x; # fails to compile

As noted above, whether a name can be reassigned or not is static and therefore up to the compiler to determine, not up to the runtime. If you're not sure what that means, it's that you'll get a detailed report of every error of this kind you make before your code runs rather than when it runs. Named values created with val are also not allowed to start uninitialized, as you'd be unable to set them to anything but nil after that.

Solus does not have a function or def keyword like other languages, instead you should either assign a function to a global (no declaration), or assign it to a local named value. Global variables are different from local ones in that they persist between files, through functions, and as long as you let them until the program has terminated. Globals can be accessed by indexing the global keyword or just using the name if no local variable already use it.

solus
var x; # init nil
x = 4;

global.x = x; # you need global to avoid ambiguity
y = x + 10; # not needed here

val fun = [](a, b) { return a + b; };

Scopes and Blocks

Scopes are a programming concept that groups related local names into a shared state, so that for example variables may shadow outer ones or release their name once they go out of scope. Understanding scopes is important to understanding where you're assigning a value.

solus
var outer = 1;
if true {
    var inner = 1;
    var outer = 2; # shadow
}

In solus, you can consider two main types of scopes: function scope and block scope. Function scope does not have access to outer variables unless they are brought in via capture (the bracketed list before the arguments). Capturing variables promotes them from local variables to shared variables, meaning they can be modified by external function calls and therefore escape their scope.

solus
var abacab = 'blood';
val modify = [abacab](new) abacab = new;
modify('meow');

Block scope begins at the start of a function, and the function's arguments and captures are inserted into that scope immediately. From then on, inner blocks are defined by curly braces {}, and out variable names may be shadowed by inner ones meaning that their name is reused for a new variable. If you need to create an inner scope in solus, you should use do {}, which also evaluates via trailing return.

solus
var ev = do {
    var x = io.fread('main');
    if x.type() == 'err'
        x = io.fread('backup');
    x
};

One thing I want to set straight in this handbook is that there is a preferred bracket style for most languages, and the one for solus is brackets on the same line as the statement or function.

Functions

Now onto the real meat and potatoes of solus, the fun type, or functions. Every unit of solus code is in fact a function, each file is simply a function that takes no arguments and returns nil by default. Standard arguments act the same as local variables, and they can be accessed with the same speed.

solus
 [self, local_var]      (arg1, arg2)            { arg1 + arg2 }
# ^- Captures           ^- Arguments            ^ Code / Return
object.call(arg1, arg2);

Reminder of the function signature format

Another feature of the function system in solus is variadic arguments and the unfold operator. Variadic arguments can be accepted using the ...extra syntax, but you are free to name the array whatever you want. Excess arguments will be placed into that array, and arrays can be unfolded into function arguments by using the same syntax.

solus
var variadic_print = [](...extra)
    extra.foreach([](v) io.println(v));
var wrapper = [variadic_print](...args) {
    io.println(args.len());
    variadic_print(...args);
};
wrapper(nil, nan, inf);

Returning values

There's two ways to return values in solus, and that's via keyword or via trailing return. Trailing return is a concept borrowed from rust that states a block should evaluate to or return the last statement if there is no semicolon at the end of the expression. You can use either method, but trailing return is recommended unless there is some ambiguity that makes it inappropriate.

solus
[](a, b) {
    if a.type() != b.type() or a.type() != 'obj'
        err('Type Mismatch') # branched trailing return
    a[0] + b[0] # return keyword can be used instead
}

Self and Member Functions

The self keyword can be used in one of two places of the function: either the arguments or the captures, and never both. In the case of a self capture, the function becomes what's called a member function. Member functions behave differently in that if they are called via . operator, the self capture will automatically be updated to the value before the .. This is not true of the self argument, where the argument must be passed traditionally.

solus
var o = {
    name = 'sarah'
    print = [self]() io.println(self.name)
};
o.print(); # sarah

The self operator is not exclusive to objects, but infact works on all dynamic types via extension. Remember that the function must be called via member call (.) for the self capture to be updated.

Another use of the self keyword is during object construction, in other words, within an object literal. During this phase, self refers to the object currently being constructed and may be used to access already initialized fields. Members are always initialized from top to bottom, so be sure to pay attention to the order of your members.

solus
{
    x = 2
    y = self.x * 8
    sub = do {
        val s = self;
        {
            _ = io.println($'{s} != {self}')
        }
    }
}

Importing Files

Because files are functions, including self-made modules is as simple as returning an object and then compiling and calling that module file as a function. This can be done one of two ways, via import/require or via include. The difference between import and require is minor, require simply panics instead of returning an error. It is the same as writing import(s).unwrap(). These functions load the file at runtime, failing if it's not found or fails to compile or run.

solus
{
    static = "yipn't"
    exported = [self](new) self.static = new
} # this is the primary use case of trailing return =)
solus
var mod = import('mod').unwrap(); # or require('mod')
mod.exported('yippee!');
io.println(mod.static);

Including files on the other hand is quite different. include is not in fact a function but a keyword, meaning that it's evaluated at compile time. It must be passed a string literal, and if it fails to compile, the outer file will also not compile. Including a file integrates it directly into the constant table of the outer file, meaning they can be consolidated into a single .solc file.

solus
var compile_time = include("local_file"); # notice the keyword coloring?

Branches and Loops

Branches are a type of statement that means a program's path of execution splits into two possible outcomes. These branch statements are very straightforward, so I'll spare the details of the definition of what the word 'if' means. None of these statements require brackets for single lines of code, but they may be preferred.

If and Else

solus
var condition = io.input('Y/N: ') == 'Y';
if condition {
    var x = 2;
    # ...
    return x;
} else if (global.parentheses)
    io.println("You also don't need braces.");

As you can see above, the if/else keywords form branching statements that should be familiar to anyone with a basic understanding of control flow. What may not be familiar to you is that there are no semicolons, parentheses, or brackets required.

solus
if im_a_failure
    err("It's so over...")
else
    return err("It's STILL so over...");

Another important thing to note here is that while branch statements do not evaluate, they can return the function when the semicolon is omitted. Keep this in mind when using if inside do {}, because it will act as the return keyword and not an evaluation.

While

While is the simplest type of loop in solus, simply testing a condition and executing a block as long as that condition remains true. A condition may be any expression that holds truthiness.

solus
var i = 0;
while i < 100 {
    io.println($'Loop {i + 1}');
    i = i + 1;
}

For

The for keyword isn't the most solus-coded statement type, but it's a staple of every programmer's toolkit and personally I take offense when the syntax of it is changed too much. The rundown of the for statement is that you have a before, condition, and after all in one loop, separated by semicolons. The before and after may be statements, but the *condition& may only be an expression.

solus
var array = { 1, 2, 3, 4, 5 };
for var i = 0; i < array.len(); ++i
    io.println(array[i]);

When running through the for loop, the before statement will be executed, then the condition will be checked. If the condition passes, the block shall run, and then the after statement will be executed. Return to condition, rinse and repeat.

Objects

Although solus is not an object-oriented programming language, and I would consider it an "all of the above" kind of deal, it does support some concepts of object-oriented programming which you can use to your advantage. More information on the metadata showcased below can be found at the Metadata section.

solus
var cat = {
    fur_color = 'orange' # silly default
    age = 0
    babies = 0
    gender = 'male'

    give_birth = [self]() {
        if self.gender == 'male' or age < 1
            err("You can't do that")
        io.println($'MEOW MEOW KITTENS! {++self.babies}');
    }

    grow_old = [self]()
        self.age += 1/365
};

var jessica = { age = 1, gender = 'female' }.usemeta({ _extend = cat });
jessica.grow_old();
jessica.give_birth();

Notice how I can set default values on the base object, and then setting them from within the member functions will set the instance's member to that value. This combined with member functions make for relatively powerful features that allows you to build classes.

One other thing I want to cover in this chapter related to objects is more efficient ways to traverse them. There are std functions that make working with objects a breeze, and luckily they're all inherited by every object.

solus
val my_obj = {
    1, 2, 3, 4,
    member1 = 2
    member2 = 'hehe'
};
my_obj.foreach([](v, i) io.println($"[{i}]: {v}")); # array
my_obj.pairs([](key, value) io.println($"{key}: {value}")); # members