smoλ

A safe & fast low-level language.

Smoλ creates zero-cost abstractions for managing data and functions. Its core is tiny — really tiny! So tiny, in fact, that functions like printing and basic arithmetic operators are part of the standard library. There's a direct-to-C++ interface for extensible functionality.

Quickstart

Look at an example program:

@include std

smo Point(i64 x, i64 y) -> @new
smo Field(Point start, Point end) -> @new

service main()
    p = Point(3,4)
    f = Field(1,2,p)
    print(f.start.x)
    print(f.end.y)
    i = add(f.start.x, f.start.y)
    print(i)
    --

First, @include brings code from other files with the .s extension. Paths are separated by dots. For safety, includes cannot occur within definitions. Circular dependencies or re-imports are not allowed either. Here we use the standard library, but you may create your own flavor.

Next are some so-called runtype definitions, indicated by the smo keyword. These merge the concept of types and functions, where the keyword -> returns a value or tuple. If variable names are part of the returned tuple, this can be accessed as fields from the result. @new is a shorthand for returning a tuple of all internal declarations.

Once you write something like p = Point(...), you can access all returned named variables using notation like p.x and p.y. You can also unpack the result as part of tuples; these are alwways flat (there is no tuple of tuples) and can be reinterpreted in various ways as long as primitive types match.

Put all main business logic inside a service main() definition. Being a service means that errors occuring inside are "caught", perform safe allocation and deallocation, and do not escape to the any code calling it. More on services later. For now, know that the main service is the entry point of programs and returns no value, as indicated by the -- symbol at its end.

Runtypes

Types and functions are the same thing in smoλ and marked as smo followed by a name and a parenthesis with some arguments. We call the merged concept runtypes. As an example, look at a definition from the standard library, which also gives a taste of the C++ ABI:

smo add(i64 x, i64 y)
    @body{i64 z=x+y;}
    -> z

This tells us that we are defining an integer addition runtype with the corresponding arguments. When called from other code, the definition is inlined in an optimization-friendly. For example, despite the illusion of typing, everything consists of direct variable operations. For example f.start.x is represented by f__start__x under the hood.

Return a value or tuple of values with ->, and use @body to write C++ code. If you don't want to return anything, use --. Return symbols form visual barriers that are easy to spot while remaining ... small. Note that smoλ does not require semicolons becase boundaries are unique: everything ends at return statements, at the end of file, or resides in-between parentheses and commas.

Mutability (call by reference)

Runtypes are called by value, that is, without internal computations affecting external arguments. You can make calls occur by reference by prepending & to variable names in the signature. In this case, internal modifications are retained. Below is an example.

@include std

smo inc(i64 &x)
    x = add(x,1)
    --

service main()
    x = 1
    inc(x)
    print(x) // 2

Currying

Runtypes accept currying notation that transfers a computed value to the arguments of the next call. The curry operator is | and can be chained. If you have multiple calls in the currying chain, preffer placing those at separate lines. This is only a stylistic choice.

Currying feature lets smoλ avoid methods as fields, as the notation obj|rt(args) is conceptually similar. Note that mutability should be explicitly declared if you want rt to have side-effects.

@include std

service main()
    1
    | add(2)
    | mul(3)
    | print()
    // equivalent to print(mul(add(1,2), 3))

Fields

The assignment operator copies the outcome of function calls to variables. However, only returned symbols can be accessed as fields. For example, below the input variable x cannot be accessed after computations conclude. If you return the outcome of some computations without packing it into a comma-separated tuple, this is not exposed either.

We already saw that it may be convenient to unpack all runtype inputs with @new to directly declare a structural type without internal implementation.

@include std

smo Test(i64 x)
    incx = add(x,1)
    -> incx

service main()
    p = Test(1)
    print(p.incx)
    print(p.x) // CREATES AN ERROR

Overloading

Overload runtypes that are structurally different when converted to a flat representation of primitives. Runtypes equivalent in terms of primitives cannot be used as part of function signatures due to ambiguity. More on circumventing this issue later. As an example, the following definitions come from the standard library.

smo print(f64 message)
    @head{#include <stdio.h>}
    @body{printf("%.6f\n", message);}
    --
smo print(i64 message)
    @head{#include <stdio.h>}
    @body{printf("%ld\n", message);}
    --
smo add(i64 x, i64 y) @body{i64 z=x+y;} -> z
smo add(f64 x, f64 y) @body{f64 z=x+y;} -> z

service main()
    print(add(1,1)) // 2
    print(add(0.2,0.3)) // 0.5

Call by type

You might want to choose a runtype's version based on another without actually passing data. For example, something different should be called based on the expected outcome. In those cases, you can skip declaring variable names in signatures, and you can ommit parenthesis-based argumets that would create dummy data.

Below is a segment of the standard library that shows how the correct version of an evoked method is applied. Runtypes without parentheses refer to zeroed out input data. You can also use a value as reference - that would be ignored.

smo not(bool x) @body{bool z=!x;} -> z
smo print(f64 message)
    @head{#include <stdio.h>}
    @body{printf("%.6f\n", message);}
    --
smo read(i64)
    @head{#include <stdio.h>}
    @body{i64 number = 0; bool success = scanf("%ld", &number);}
    if(success|not()) @fail{printf("Invalid integer\n");} --
    -> number
smo read(f64)
    @head{#include <stdio.h>}
    @body{f64 number = 0; bool success = scanf("%lf", &number);}
    if(success|not()) @fail{printf("Invalid number\n");} --
    -> number

service main()
    x = read(f64)
    print(x)

Unions

Sometimes, you want to define code that is automatically adjusted to different runtypes. This can be achieved by declaring runtype unions, which are resolved to one of their options. The resolution persists to dependent calls, so yo can create different ones for independence. Unions are resolved during compilation and, like many features of smoλ, are zero-cost abstractions. They can also unpack unions provided as arguments.

For example, the Type union below is determined to be f64 while calling inc to match the Point argument and carries over to the internal implementation. Therefore, the f64 primitive is used for reading, constructing a two-dimensional point, for casting the value of 1 to the appropriate type, and calling the corresponding overloaded addition. Unions account for overloads of their options up to the point where they are defined.

@include std

union Type(i64, f64, u64)
smo Point(Type x, Type y) -> @new
smo inc(Point &p)
    p.x = add(p.x, Type(1))
    p.y = add(p.y, Type(1))
    --

service main()
    value = f64|read()
    p = Point(value, value)
    p|inc()
    print(p.x)

Buffers

Handle dynamic memory with the special buffer runtype. Think of this as a list where new data are pushed to the back and popped from the front. The definition is part of the language and it is how one would handle functions with variadic inputs too.

Buffers are unpacked into other runtypes by consuming elements from their start. However, unpacking feasibility is checked at runtime. Memory deallocation is safe, occurs always -even if runtime errors occur to terminate a service- and is injected automatically by smoλ. That said, data are stored in buffers without even primitive types and unpacking relies solely on the programmer.

Buffer elements are unpacked from the front until no more entries are required for desired runtype calls. Popping is memory safe in that it smoothly fails any service (e.g., main) with an appropriate error message.

@include std

smo Point(i64 x, i64 y) -> @new
smo Field(Point start, Point end) -> @new

service main()
    buf = buffer(1,2,3,4,5)
    f = Field(buf)
    print(f.start.x) // 1
    print(i64(buf)) // 5

Slicing

Buffers can be sliced to obtain a sub-view of elements. This happens irrespectively of other operations applied on the original. Slices use square brackets and either contain a u64 number indicating a starting element to be followed to the end, or contain starting and non-inclusive end elements separated by a double colon. Below is an example that demonstrates element access. All operations are near-zero-cost abstractions, with the exception that a slice with specified end checks for bounds.
@include std

service main()
    buf = buffer(0,1,2,3,4,5)
    slice = buf[u64(1):u64(3)]
    print(i64(slice))        // 1 (pops front from slice)
    print(i64(buf[u64(0)]))  // 0 (buf[0] is also a slice)
    print(i64(buf))          // 0
    print(i64(slice))        // 2 (slice remains unaffected)
    print(i64(slice))        // CREATES RUNTIME ERROR

Concatenation

Normally, you can have a buffer as the last argument so that popping knows how many elements it needs to consume. The language offers three operations for buffers: reserve preallocates memory (for optimization, otherwise size is doubled when needed), push adds data at the end, copy copies contents without altering the original, and assigning inserts data at the beginning as long as the size is compatible

@include std

smo Point(i64 x, i64 y) -> @new

service main()
    buf1 = buffer(1,2)
    buf2 = buffer()|reserve(2)|push(3,4)
    buf = buf1
          | copy()
          | push(buf2.copy())
    // buf1 and buf2 are empty now
    p1 = Point(buf)
    p2 = Point(buf)
    print(p1.x) // 1
    print(p2.y) // 4

Std

Conditional if statements are second-class citizens in smoλ and are manually implemented in the standard library. They do no accept an alternative branch. Either run multiple of those, or enclose them in the scope runtype. The latter creates an independent scope from which you can return. An example follows.

@include std

smo print_sign(i64 i)
    sgn = do
      if(i|gt(0)) -> 1
      if(i|lt(0)) -> -1
      -> 0
    print(sgn)
    --

service main()
    print_sign(100)
    print_sign(-42)

Like all runtypes, you can return values from conditional statements. If the condition does not run, variables are zeroed out as default behavior.

@include std

service sign(i64 i)
    if(gt(i,0)) --> 1
    if(lt(i,0)) --> -1
    -> 0

service main()
    x = sign(100)
    y = sign(-42)
    print(x)
    print(y)