Section 1. Basic Syntax
1.1. import
1.2. calling notation
1.3. mutability
1.4. types
1.5. type mutability
1.6. conditions
1.7. recursion
1.8. effects
1.9. unions
1.10. literal types
1.11. conditional compilation and default arguments
1.12. compile-time evaluation

Section 2. Safe Resources
2.1. buffers
2.2. {pointers}
2.3. {substructures}
2.4. stable references
2.5. try and fail
2.6. {iterators}
2.7. {defer}
2.8. catching errors
2.9. allocator-effects
2.10. functors
2.11. {debugging tools}
2.12. {bounded compute}

Section 3. Standard Library
3.1. lists
3.2. strings
3.3. maps
3.4. io
3.5. processes
3.6. random
3.7. {mini and bits}
3.8. vectors
3.9. matrices and graphs
3.10. graphics
3.11. process and web

{…} are advanced functionality

Section 1. Basic Syntax

import

Here is how to import the entire contents of another source code file. The print and console functions are imported from the core. Assigning to the CLI variable tells the program to print to the command line interface, that is, the currently open terminal. The edit before the console call tells the program that we intent to modify console contents, and not merely perform some diagnostic action. This is an effect, though before learning about those just treat it as boilerplate for simple programs.

import "std/core.s"

def main()
    # this is a line comment, by the way
    CLI = edit console() 
    print "hello world!"

You need only the executable (and a local C compiler) to start working. Once you download the language’s executable, you can reference local or online directories in your code. Online dependencies are automatically downloaded. Below is an example, where the theoretical std/ location is grabbed from the development repository. For safety, repositories in other than the main file only make suggestions, and fail to compile if they are not permitted in the main file too.

repo "https://raw.githubusercontent.com/maniospas/smoll/refs/heads/main/std/" as "std/"
import "std/core.s"

def main()
    CLI = edit console() 
    print "hello world!"

You can also import a file as a namespace to access its contents with the :: notation. This is more verbose but unambiguous, like below. You can also access child namespaces.

import "std/core.s" as core

def main()
    CLI = edit console() 
    core::print "hello world!"

If you want to import something specific from a namespace, use : within the import statement. Using the path instead of the namespace name is also allowed. Finally, have imports -or function definitions- be preceded by local to avoid exposing definition to files up the import chain. This is used for isolating which functionality is introduced in each file.

local import "std/core.s"::print

def main()
    CLI = edit console() 
    print "hello world!"

calling notation

Functions are followed by their arguments in parentheses, although you can also omit the latter if they would end at the end of the line. Arguments are comma-separated, like below. In general, commas within parentheses designate tuples.

import "std/core.s"

def main()
    CLI = edit console() 
    p = (1,2)
    print add p
    print add(1,2)

The . operator can also pipe some data into the beginning of a function like below. This works as a notation for calling functions like class methods. You can always refer to functions within namespaces.

# atypical import for core - for demonstration
import "std/core.s" as core

def main()
    CLI = edit console() 
    x1 = 1
    x2 = x1.core::add 1
    core::print x2.core::add 2

Avoid needless parentheses, as the snippet below does; f1 f2 ... args is a chain of function calls, read left to right. Always read function calls from left to right, considering all the subsequent contents to be part of the call.

One particularly useful function from the standard library’s core is nn, standing for no \n; it takes numbers or strings and outputs a tuple of the original value accompanied by "". When passed to the print function, this tuple also sets the optional second argument determinin line ending. The default ending is otherwise "\n".

import "std/core.s"

def main()
    CLI = edit console() 
    print nn "hello " # equivalent to print("hello", "")
    print "world!" 

mutability

Varaibles cannot normally be overwritten. For example, setting x=1 prevents overwriting x with another value. This property is called immutability. To allow oeverwriting, place mut just after the first assignment to indicate that the variable is instead mutasble.

You can keep overwriting mutable variables. This is much more intrusive than the edit qualifier that we have been using to edit the console. For example, primitive types -like numbers- cannot be edited because they have no internal state. But you can set them as mut to allow replacement with a different value, like below. That said, even if a value is mutable, it can only be overwritten by others of the same type.

In most code, variables will remain immutable. Prefer using edit when possible, as it is the more restrictive of the two permission levels. See more on edit permissions later.

import "std/core.s"

def main()
    CLI = edit console() 
    x = mut 1  # mutable - we want to mutate it further
    x = x+2
    x = x+3
    print x

types

All functions declare corresponding types via their returned values. That is, you can use the function’s name to refer to data with equivalent structure. Below is an example, where the nat type represents to natural numbers/non-negative integers. Other builtin types are bool, int, float, and cstr for string literals (string literals are lightweight but can not be dynamically constructed or edited: use str covered later for this). It bears stressing that, when you see integer numbers in the code, these are always natural numbers. This is a deliberate choise to enforce memory safety later.

import "std/core.s"

def toinfinity(nat start)
    pos = mut start
    return (start, pos)

def next(edit toinfinity r) # allow modification of mut fields only
    r.pos = r.pos+1

def main()
    CLI = edit console() 
    r = toinfinity 0
    next r # proper loops later
    print r.pos
    print add r

The example above uses the . notation to obtain
a value packed in a type by name. This name is determined by the returned value’s name.

Types like the above are structurally matched, as we did when applying add to the range construct. This structural typing is rich but can be kind of unsafe if you do not keep track of your data shapes in that you may accidentally allow applying the same functions to unforeseen data structures. This weakness is also its stronges theoretical benefit, though; it becomes a determine only for structures that also require some relational invariant between values.

To prevent implicit structural matches, use the following class notation to wrap the returned value. You will mainly want to do so when the relations between class field values (e.g., a character buffer and its used size) is important to safeguard.

import "std/core.s"

def Point(float x, float y)
    return class(x,y)

def sum(Point p)
    return p.x+p.y

def main()
    CLI = edit console() 
    p = Point(1.0, 2.0)
    print sum p 

Further prefer class definitions when declaring types with some construction contract; such contracts should not be violated by matching with arbitrary data. For example, below is a data type that can track the sum and sum of squares of some floats, as well as the number of floats. This can be used to register more floats, or compute the standard deviation of all encounted floats, but not any (float, float, nat) triple should be allowed.

import "std/core.s"
import "std/sci.s" # for pow

def std_data()
    sum = mut 0.0
    sqr_sum = mut 0.0
    num = mut 0
    return class(sum, sqr_sum, num)

def register(edit std_data data, float value)
    data.sum = data.sum + value
    data.sqr_sum = data.sqr_sum + value*value
    data.num = data.num + 1

def std(std_data data)
    sqr_mean = data.sqr_sum/float data.num
    mean = data.sum/float data.num
    return pow(sqr_mean-mean*mean, 0.5)

def main()
    CLI = edit console() 
    data = std_data()
    data.register 1.0
    data.register 1.0
    data.register 2.3
    print std data # prints 0.612826

Use singleton instead of class to further ensure that the function runs at most once in your program. On the other hand, a shorthand for defining a structural type that would immediately return all its fields is to omit its body, like next.

import "std/core.s"

def Point(float x, float y) # structural definition needs no body
def Field(Point a, Point b)

def main()
    CLI = edit console() 
    f = Field(1.0, 2.0, 3.0, 4.0)
    print f.a.x+f.b.y # prints 5.0

One notable singleton in the standard library is the console used to represent the system terminal in which the program is running. Being a singleton forces sequential execution order, even in programs that leverage parallelism. Below is an example where this singleton is used to also read a float number. Similar methods exist for reading strings on buffers, or other number types.

import "std/core.s"

def main()
    CLI = edit console() 
    print nn "Give a number: "
    x = float CLI
    print nn "Its square is: "
    print x*x

type mutability

Normal mutability rules apply when overwriting whole objects. For example, t below needs to be made mutable so that it can be overwritten. This does not mean that field immutability can be violated. That is, even if t is mutable, we would not be able to overwrite the immutable t.y field by itself. In fact, we can modify modify mutable fields only if t has mutation or edit permissions.

import "std/core.s"

def Test()
    x = mut 1
    y = 2
    return class(x, y)

def main()
    CLI = edit console() 
    t = mut Test()
    t = Test() # overwrite 't' completely - remains mutable
    print(t.x) 

If you do not use a mutation identifier like mut or edit, then variables -including their fields- are considered fully immutable. That is, they cannot be modified or be the source of memory content modifications.

import "std/core.s"

def Test()
    x = mut 1
    y = 2
    return class(x, y)

def main()
    CLI = edit console() 
    t = edit Test()
    print t.x # prints 1
    t.x = 0
    print t.x # prints 0
    print t.y # prints 2

It has been already mentioned, but is worth repeating: to preserve mutation for mutable fields without allowing a full variable rewrite use edit.

import "std/core.s"

def Test()
    x = mut 1
    y = 2
    return class(x, y)

def test(edit Test t)
    t.x = 10 # allowed only thanks to 'edit'
    print t.x

def main()
    CLI = edit console() 
    t = Test()
    t.x = 5
    test t

conditions

As you have likely noticed so far, smoλ code blocks are distinguished by indentation. The same holds true for code blocks of if-else conditions and while loops. Both behave like how you would expect, and loops can also be prematurely terminated with break, or skipped with continue.

import "std/core.s"

def main()
    CLI = edit console() 
    x = 1.0-2.0
    if x<0.0
        print "x is negative"
    print "done"

You can have one-liners for conditions and loops, like in the following version. These one-liners parse exactly one statement (one expression, or one condition, etc).

import "std/core.s"

def main()
    CLI = edit console()
    x = 1.0-2.0
    if x<0.0 print "x is negative"
    else if x==0.0 print "x is zero"
    else print "x is positive"
import "std/core.s"

def main()
    CLI = edit console()
    x = 1.0-2.0
    if x<0.0 sgn = "-" else sgn = "+"
    print nn "sign is: "
    print sgn

Loops are similar to conditions, but execute multiple times until they check to false. You can use continue to skip the rest of the current loop and break to halt it. Here is a simple example. Please do not write code like this.

import "std/core.s"

def main()
    CLI = edit console() 
    i = mut 0
    while true # overengineered
        if i==5 
            break # stop this way
        if i==3
            i = i+1 # skip printing 3 
            continue 
        print i
        i = i+1

A more efficient way to write loops, is with the for variable in iterator pattern, which uses iterators that have the means to obtain the next element. The range iterator, for example, takes a pair of start and non-inclusive end numbers and allows calling next to retrieve the next value until the end is reached (non-inclusively). Use the of function to support various means of expressing such value pairs. Here are some examples, which se literals for explicitness:

Iterators safely try to keep producting new elements until they fail to do so (any kind of failure stops them). Underneath, they desugar to error code semantics. More on iterators later.

import "std/core.s"

def main()
    CLI = edit console() 
    for i in range of 10
        print i

Smoλ provides and and or operators. These work on boolean variables like normal, but have two more properties:

Below is an example that highlights the short-circuiting properties of logical operators.

import "std/core.s"

def point(float x, float y)
def add(point p1, point p2)
    return point(p1.x+p2.x, p1.y+p2.y)
def all_positives(point p)
    return p.x>0.0 and p.y>0.0
def not(point p)
    return point(1.0-p.x, 1.0-p.y)
def main()
    CLI = edit console() 
    p = mut point(10.0, 20.0)
    # 'neg' to make numbers negative
    p = (all_positives p) and add(p, neg 30.0, neg 30.0) 
    print p.x # -20.0
    print p.y # -10.0
    p = (not all_positives p) or (1.0,1.0)
    print p.x # 1.0
    print p.y # 1.0

recursion

Sometimes, it is convenient for functions to call each other. Normally, functions can see only previous ones, but you can use rec instead of def to allow recursion within the current file.

A file with recursive functions works like this:

Importantly, the recursive function’s escape hatch should occur first, otherwise Step 1 would result in failure. The compiler will point out that you are trying to perform a recursion without having returned at least once first so that the type can be determined.

For example, consider the following malformed program; type theory would be able to determine a type for this case… but smoλ disagrees because doing so in more complicated cases means that reading the program sequentially does not work; the programmer needs to reason about unbounded problems.

import "std/core.s"

def wooo() # CREATES AN ERROR - should have been 'rec'
    CLI = edit console()
    print "wooo"
    return wooo()

def main()
    wooo()
[+] process tests/test.s type error: recursive usage of 'def wooo' before its definition; perhaps declare it as 'rec' at tests/test.s line 5 column 12 return wooo() ^^^^

Smoλ’s type system is deliberately simplified so that programs can be read sequentially and resolve types in finite time. Recursion is too convenient to disallow fully, so explicit recursive declarations are selected as a means of presenting the aforementioned properties. In particular, you can read up to a recursive function’s first return and keep that in mind whenever the function is called in subsequent code.

Below is an overengineered and thus algorithmically slow Fibonacci number calculator that demonstrates recursion concepts. The function call_fib is completely passthrough and hence useless. :-)

Recall that recursive functions can call both themselves and others that appear later. But normal functions can still only see previous declarations. Thus, only one function in a chain of multiple ones needs to be declared as recursive.

import "std/core.s"

rec fib(nat n)
    if n<=1
        return 1
    return call_fib(n-1)+call_fib(n-2)

def call_fib(nat n)
    return fib(n)

def main()
    CLI = edit console()
    print fib(10) # prints 89

But sometimes we really need unbounded recursion, right? A simple trick is to declare returns that never run, like below. Why returns that “never” run are useful in edge cases will be discussed later, in the bounded compute section. For now, just it suffices to say that “never” does not actually mean never.

import "std/core.s"

rec wooo(edit console CLI) # not the ideal way to pass the console - see next section
    if false return blank()
    CLI.print "wooo"
    wooo CLI

def main()
    wooo console()

effects

No, don’t run!

Smoλ deliberately avoids a complicated effect system; it just presents the ability to pass some arguments implicitly from the calling scope. In the standard library later, this mechanism is mainly used for passing memory allocators around. However, we have already seen a much more fundamental usage in declaring CLI = edit console() in almost all our main functions.

You can have the effect keyword before an argument in a function’s signature to declare an effect. There is no other difference in how you would write or call the function. You only get one additional benefit; the compiler will try to pull variables with the same name from the calling scope. Effects can only be placed at the start of funtion, and gathered scope variables will be placed there too.

Below is an example. Avoid using effects when not needed for clarity, as they introduce calling complexity in that you are not explicitly passing arguments around. That said, you can explicitly pass all function arguments, in which case functions are called normally and nothing is gathered; this prevents accidental implicit behavior. By convention, CAPITALIZE effect names so that assigning to them is made explicit by the code.

import "std/core.s"

def inc(effect nat INCREMENT, effect mut nat COUNTER, nat number)
    COUNTER = COUNTER+1
    return number+INCREMENT

def main()
    CLI = edit console()
    COUNTER = mut 0
    INCREMENT = 1
    print inc inc inc 9      # prints 12
    print inc(3, COUNTER, 4) # prints 7
    print COUNTER            # prints 4 ('inc' was called four times)

Seing the above example, we can finally (and only now!) learn how to print in functions other than main; pass the CLI effect. Like below. Importantly, the console is a singleton() and thus can be created only at one place in a program. The added boilerplate is well worth the effort; even the console initialization is used to ensure that a printable console exists.

import "std/core.s"

def greet(effect edit console CLI)
    print "hello world"

def main()
    CLI = edit console()
    greet()

unions

Declare alternatives between types (unions) by separating them with |. Below is an example that defines a function for adding either a float or an integer to a float. The brackets are used to add some C code, inside which builtins::float is injected by smoλ as the appropriate builtin type.

The exemplified functionality of adding any two numbers together is not part of the standard library, as it introduces lossy conversion between numeric types. Usually, you will not see any unsafe C code -introduced by brackets- either, unless you are contributing to the standard library or writing helpers for low-level operations. A more complete reference for interweaving a superset of C that interacts with smoll will be added in the future.

import "builtins"
import "std/core.s":print

def unsafe_add(float x, float|int y)
    {builtins::float z=x+y;}
    return z

def main()
    CLI = edit console()
    print unsafe_add (1.0, int 2)

Type alternatives can also be named for reusability. This is called a union type in that it brings together several alterantives to be referenced via the same name. An example follows.

import "builtins"
import "std/core.s":print

def Number = float|int|nat
def unsafe_add(Number x, Number y)
    {builtins::float z=x+y;}
    return z

def main()
    CLI = edit console()
    print unsafe_add (1.0, 2)
Warning: You can skip the more complicated aspects of the type system in the rest of this subsection. Unlike |, they are only rarely needed, especially in well-structured code that defines types incrementally.

In truth, smoλ implements a linear type system, but this was hidden till this point because people tend to shy away from reading technical terms. Practically, it means that -in addition to type unions- you can also get the intersection of type unions with the & symbol. Use parantheses like normal.

Next is an example where, say, we define a float function that returns something other than a builtin float number. We can get the intersection of all float definitions that are also numbers, or all those definitions that are not numbers with the ‘` symbol, which is the mathematical symbol for set differces.

import "std/core.s" # defines Number like above

def natpair(nat x, nat y)
def float(natpair a)
    x = float a.x
    y = float a.y
    return (x,y)

def numeric_float = float&Number
def inc(numeric_float x)
    return 1.0+x

def inc(float\Number a) # define for float definitions that are not in Number
    x = 1.0+float a.x
    y = 1.0+float a.y
    return (x,y)

def main()
    CLI = edit console()
    print inc(1.0)  # print 2.0
    print inc(4.0,4.0).x # prints 5.0

literal types

Often, there is a need to distinguish functionality based on some exact literal value. Or you may want to have some constant that is used everywhere in your code. This is done by having literal types, as in, types that are attached to specific values.

Below is an example on using this concept to define numeric or cstr constants first. Literal types evaluate to their value when used within code - nothing out of the ordinary. By the way, prefer CAPITALIZING literal types (so capitals indicate either effects or literals).

import "std/core.s"

def INCREMENT = 1
def inc(nat x)
    return x+INCREMENT

def main()
    CLI = edit console()
    print inc 0  # prints 1

But literal types can also select which function to call, like below. Doing so requires retrieving the literal’s type by evaluating type value, where value can be a string or number literal.

import "std/core.s"

def VERSION = "two"
def version(effect edit console CLI, "one") # just a literal type
    print "version one"
def version(effect edit console CLI, "two")
    print "version two"

def main()
    CLI = edit console()
    v = type "two"
    version v # calls the correct version
    version type VERSION # calls the same version

Literal types are still types and are associated to named variables. For example, "two" number_name is a valid function argument of type "two". This is not a value and yout cannot, say, print it.

But you can get back the value associated value with the type via the compiler::value function. That is, compiler::value type "two" yields the cstr value "two". This way, you can extract values from the type system. An example that restricts how functions are called is presented next.

import "std/core.s"

def inc(nat x, blank|1|2 inc)
    if inc is blank  # check if exists - see next section
        inc = type 1 # literal convertible
    return x+compiler::value inc

def main()
    CLI = edit console()
    print inc 0           # prints 1
    print inc (0, type 2) # prints 2

Check whether a variable belongs to a literal type by performing an is check to obtain a boolean value. Contrary to the zero-cost conditional compilation of the next section, which has zero runtime overhead, literal types may be resolved to several runtime checks.

import "std/core.s"

def ENUM = "A"|"B"|"C"
def answer_schemas(ENUM first, ENUM second, nat minutes_to_answer)
def answers(cstr first, cstr second, nat minutes_to_answer)

def main()
    CLI = edit console()
    answers = answers("A", "F", 60)
    if not answers is answer_schemas 
        print "not a valid answer" 
        fail "not a valid answer" # this will fail
    print "answered: "
    print answers.first
    print answers.second
    print ("in", " ")
    print (answers.minutes_to_answer, " minutes\n")

Literal types culminate in a feature that allows usage of the type name as a language keyword in place of a comma separator. In particular, two tuple elements (such as function arguments) can be separated by a single word instead of commas, and that word resolves to a variable of the same-named type literal. Below is an example.

Do remember that this syntax replaces the comma separator. So it occurs after an expression ends. Therefore, to convert two literals back-to-back, you need use a placeholder with no outputs as an argument, like blank(). But you can still have a literal at the end of an expression.

import "std/core.s"

def modify(mut nat x, "add", "one")
    x = x+1
def modify(mut nat x, "add", nat y)
    x = x+y
def modify(mut nat x, "sub", nat y)
    x = x-y
def main()
    CLI = edit console()
    x = mut 5
    modify(x add 3)
    modify(x add blank() one)
    print x # prints 9

conditional compilation and default arguments

Info: In conjunction with the type system, this feature creates zero-cost condition checks for concise creation of multiple definitions.

You can use the [value] is [type] operator to check that a value/tuple adheres to at least one variation of a union. When no literal types are involved (and sometimes when they are), the result is not merely a boolean, but in fact of type compile:true or compile:false; these values are significant because they let smoλ identify whether conditions will always be true or false and eliminate code without parsing it.

In other words, you can make is checks to determine conditionally which code segment to compile. This incurs zero runtime overhead. However, conditions that involve such checks must start at new lines to avoid ambiguity in case their body is not parsed.

At the same time, you can mingle them together with other condition checking, as the standard library’s core. Below is an example of a conditional check.

import "std/core.s"

def typed_print(effect edit console CLI, nat|int|float|cstr value) 
    if value is nat|int|float
        print nn "this is a number: "
    else
        print nn "this is a string: "
    print value

def main()
    CLI = edit console()
    typed_print 1
    typed_print 2.0
    typed_print "test"

The same mechanism can be used to create optional arguments using the blank builtin type; that has no contents and therefore skips respective variable definition. Conversely, non-existing variables are considered blank, so that blank arguments can typecheck to their type correctly. In the end, you define optional arguments and their fallback values. In the next example, the defined function uses either an increment value of one, or a value provided as second argument.

import "std/core.s"

def inc(nat x, nat|blank value)
    if value is blank
        value = 1
    return x+value

def main()
    CLI = edit console()
    print inc 2    # prints 3
    print inc(2,2) # prints 4

Here is a much more complicated scenario, where compiler::skip() prevents certain versions of the function from being created (e.g. there is no inc(float,int)). The example also reveals three type inference constructs.

In total, the compiler investigates 3*4=12 variations and eventually keeps 6 of them. Both conditional checks in the example occur only during compilation and do not affect running time.

import "std/core.s"

def Num = float|int|nat
def inc(Num x, Num|blank value) # equivalent: def inc(float|int|nat x, float|int|nat|blank value) 
    if value is blank
        value = Num 1->type x # one with the same number format as x
    if not value is type x
        compiler::skip() # skip invalid 'inc' definitions
    return x+value

def main()
    CLI = edit console()
    print inc 2.0  # prints 3.0
    print inc(2,2) # prints 4

compile-time evaluation

Rember value literals? There exists actually a more general mechanism that packs a tuple of values onto a literal after evaluating them at compilation time. This evaluation uses a lightweight built-in interpreter and is initiated with the compt keyword. Next is an example.

import "std/core.s"

def VALUES = compt (1,2)
def main()
    CLI = edit console()
    print add VALUES

Next is a more complicated scenario where several functions build a temporary cstr to be used during exectuion. The interpreter is robust against even unsafe functions by showing errors when things go wrong.

import "std/core.s"

def CONSTANT = compt cstr unsafe_temp add(arena alloc 128, "hello", " world!")
def main()
    CLI = edit console()
    print CONSTANT
    print CONSTANT=="hello world!" # 'true'
    # the above check is correct even if the
    # comparison of cstr is done via pointers

Even simple memory layouts (that do not nest pointer indirection) can be ported from compt as constants. An example using vector operations is shown below. See more about vectors later; for now it is important to see that a constant data segment is ported over to runtime.

import "std/core.s"
import "std/sci.s"

def ones = compt vec [1.0, 1.0]
def main()
    CLI = edit console()
    v = mut vec [5.0, 10.0]
    allocator = arena float[].alloc 128
    v = v+ones
    print v[0] # prints 6.0
    print v[1] # prints 11.0

Lastly, leverage this mechanism to run code during compilations, like below.

import "std/core.s"

def main()
    compt print "compiling"
    print "running"
Warning: Compile-time evaluation is computationally bounded by force, operating in a few KB of memory, and up to a million unoptimized operations that translate to less than a second of running time in modern computers. However, overusing this feature may still make your programs slow.

Section 2. Safe Resources

buffers

Buffers are memory-allocated collections of items and can be declared as T[], where T is a type. Create and allocate a buffer with a pattern that looks likt this: buf = float[].alloc 4. This creates a new buffer storing float nubmers (has type float[]) that contains four numbers.

Allocation will create an error if it is called on an existing buffer to change its number of elements from non-zero to something different. In those cases, use resize instead, though the exact usage of this is covered in stable references.

A second important feature is the element access operator buffer[pos], which can be used to extract an object stored at a specific position. In general, this operator is implemented by overloading the get and mutget functions; beware that details involve pointers tackled in the next section. Use buffer[pos] = value to copy some data on a buffer’s element.

All buffer indexes are referenced by nat numbers, that is, natural numbers/non-negative integers. This is the default integer type, assumed when writing numbers like 1 to avoid a whole area of logic bugs associated with negative indexes. Next is an example of buffer usage.

import "std/core.s"

def main()
    CLI = edit console()
    buf = float[].alloc 10 # allocate 10 elements
    print buf[0] # prints 0, as buffers are zero-initialized
    buf[1] = 1.0
    print buf[1]

The outcome of allocation is a mutable buffer. Declare this as as const to forcefully disallow content modifications or resizing. That said, function arguments are const unless indicated otherwise so tere will only rarely be need to use this keyword. Below is any example of how the compiler enforces this constraint. The compiler does track some additional information like element size in bytes and, importantly, if there are dependent buffers from which the type is inferred.

In this setting, it is not allowed to wrongfully promote the buffer buf to being mutable; elevating buffer or -more generally- pointer pemissions from constant to mutable is not allowed for safety.

import "std/core.s"

def create()
    buf = float[].alloc 2
    buf[0] = 1.0
    buf[1] = 2.0
    return const buf

def main()
    CLI = edit console()
    buf = create()
    print buf[0]
    buf[1] = 1.0 # CREATES AN ERROR
[+] process tests/test.s type error: could not resolve any call for 'mutget(const float[] {element size 8} {follows float ptr ..unsafe_ptr}, nat) -> any' alternatives - mutget(edit any[] {element size ?}, nat i) -> (mut any ptr {follows any ptr buffer.unsafe_ptr}) - mutget(list, nat pos) -> (mut any ptr ret {follows any ptr l.buffer.unsafe_ptr}) at tests/test.s line 5 column 12 buf[1] = 1.0 # CREATES AN ERROR ^

Use any as the type to mark functions with type-agnostic buffer operations. The original type cannot be retrieved back while those functions are running, but this operation is still useful for writing generic code that depends on memory contents and data sizes packed within buffer metadata. If you run a function with such generic typing, the compiler identifies the correct output types for buffers of pointers. For example, the abovedescribed allocation mechanism is implemented for generic buffers and automatically converted back into the input buffer type.

import "std/core.s"

def print(any[] buffer)
    CLI = edit console()
    print(len buffer, " elements in buffer\n")

def main()
    x = float[]
    print(x)

For function arguments that need to be specialized on a buffer type, declare an intermediate type like below.

import "std/core.s"

def named_buffer(str name, edit any[] buf)
    return class(name, buf)

def named_strbuffer()
    return named_buffer(str "", str[])

def populate(edit named_strbuffer named) # for str[] only
    named.buf[0] = str "hello"
    named.buf[1] = str "world"

def main()
    CLI = edit console()
    elements = named_buffer(str "greeting", str[])
    elements.buf = elements.buf.alloc 2
    populate elements
    print elements.buf[0]
    print elements.buf[1]

Below is a convenient shorthand for creating and populating a buffer with comma-separated contents by placing those within [...]. Do note that buffer types use no contents in the brackets normally. Under the hood, the currently available alloc function is used to make the allocation.

import "std/core.s"

def print(effect edit console CLI, cstr[] sentences)
    for sentence in sentences
        print sentence

def main()
    CLI = edit console()
    print ["hello world!", "... and goodbye for now."]

pointers

Warning: This subsection describes advanced functionality that is not necessary for common or introductory language usage. You can skip it.

Pointers reference specific memory locations in buffers. Use them to quickly move data around while sharing only one memory address. This abstraction remains safe as long as you do not deliberately inject unsafe C code.

Obtain a const pointer from a buffer, including a buffer whose pointed memory location cannot be modified, per ptr = buf[element]&, and a mut pointer -if permissions allow it- per ptr = buf[element]&&. Constness means that pointer contents cannot be overwritten. To set a new value to a mutable pointer use && after the value again per old_ptr = new_ptr&&. Otherwise, assignment copies a compatible structure’s contents onto a mutable pointer’s memory location.

The compiler::deref function dereferences pointers onto local objects. For example, compiler.deref(ptr).field gets a field from an object, after dereferencing the latter. Given all these operators it is now possible to explain that buffer[pos]=value desugars to the pointer notation buffer[pos]&& = value that first retrieves a mutable pointer and then assigns to it.

Smoλ’s compiler checks on pointer safety and creates errors if it cannot prove safe behavior. By not relying on programmatic semantics other than mutability, a wide breadth of programs is allowed. The compiler will mainly ask to return together all values whose internal pointers may have become tangled in that they could depend on each other. This tangling would be the result of function calling and returning, but rarely gets in the way.

For example, if you allocate a character buffer to place strings inside and then store those strings on second buffer, the compiler requires that the two buffers are returned together. Or you may be asked to not invalidate pointers with del, if that would free memory that is used elsewhere.

Functions declare pointer arguments per any ptr, float ptr, etc. Contrary to buffers, the type remains only a pure ptr to enable several type system conveniences, and the associated pointer contents are tracked separately. Usually, functions implementing pointers should be specialized to a specific type that needs to be moved quickly. Use buffers otherwise.

Below is an example that creates a buffer of one element and immediately retrieves a pointer to it. Then that is moved around via its memory address.

import "std/core.s"

def inc(mut float ptr element)
    element = 1.0+compiler::deref element

def main()
    CLI = edit console()
    element = [0.0]& # equivalent to element = [0.0][0]&& 
    print compiler::deref element # prints 0.0
    inc element
    print compiler::deref element # prints 1,0

Creating many pointers this way is inefficient in that it produce a lot of small allocations. Many languages allow this by imposing a ton of restructions, but smoλ heavily discourages the practice, as returning pointers cannot be done without the associated buffer that contains all the necessary information of how to deallocate memory.

substructures

Warning: This is an advanced feature, mainly useful for iterating through complicatedly packed data. You can skip it.

You can move around date from buffers and pointers without copying by retrieving offsets. To ensure safety, normal code only lets pointers explitly reference buffer data. Unsafe code is either inlined C, or has unsafe in its import path or name.

All pointers are associated with types. That is not part of the pointer’s type so that the few but really important unsafe parts can run wild. However, what is worth mentioning is that, given a pointer or buffer, it is possible to retrieve a respective pointer or type to a substructure within its data type. For example, say that you have a pointer p to a (float x,float y) structure. You can get a pointer to its x field with the dot notation per p.x.

In general, use the buf.field or ptr.field notation, where the field refers to a known field of the attached structure. This operator helps write very safe yet fast and memory-efficient code by obtaining necessary offsets within allocated memory. Below is an example:

import "std/core.s"

def Point2D(float x, float y)
    return class(x,y)

def Point3D(float x, float y, float z)
    plane = Point2D(x,y)
    return (plane,class(z))

def main()
    CLI = edit console()
    points = Point3D[].alloc 10
    points[0] = Point3D(1.0,2.0,3.0)
    plane = points.plane # can move this around as if it were a buffer
    print plane.x[0]
    print points[0].plane.x # equivalent data path

stable references

You can work with data that reference other data in that they are updated together. This is similar to pointers, but comes under the safety restriction that you cannot change where references point to, even with mut.

At the same time, references are dissolved during returns into actual values that are not automatically updated together anymore - though safety checks are still performed.

To convert some data to a reference use the ref value syntax like below.

import "std/core.s"

def main()
    CLI = edit console()
    x = mut 0
    y = ref x
    z = ref x
    x = 2
    print y+z # prints 4

References are automatically propagated by analyzing direct input-output equalities. That is, it is tracked which inputs are returned directly by being assigned to output variables of function calls, and therefore references to the inputs become equivalent to references to the outputs.

This allows the compiler to re-attach valid references to invalidated data structures when those change to something valid. For example, resizing a buffer invalidates its original content address pointer, but since the value is returned and assigned on the same variable, the new address is tracked instead.

There will always be proper inference for direct equalities as long as these are not deliberately invalidated (e.g., by adding zero) and do not pass through unsafe C sections. The compiler also always creates error messages instead of triggering unsafe behavior.

References are not types but just some property attached to local variables. Below is an example where a buffer needs to be resized and this requires stable handling through ref.

import "std/core.s"

def main()
    CLI = edit console()
    buf = ref alloc (mut float[], 10) # 'ref' is mandatory to resize later
    buf[1] = 1.0
    buf.resize 20
    print buf[1]

Below is a more complicated example, where a list is used to dynamically manage a buffer and resize it as needed.

Without ref, the compiler would complain that the potential resizing of the second copy could (in this case: would) invalidate the buffer version that the string s1 know about. However, thanks to the stable reference, the buffer is automatically updated for strings copied onto it.

import "std/core.s"

def test()
    mem = list ref mut char[]
    s1 = mem.copy "123"
    s2 = mem.copy "456"
    return (s1,s2)

def main()
    CLI = edit console()
    s = test()
    print s.s1
    print s.s2

You can -and often should- mark the arguments of functions with ref instead of mut or const to indicate that the contents of memory pointers or other resources might change but that their internals are safeguarded. This is not usually needed.

try and fail

Functions in smoλ can fail freely when unforeseen conditions are encountered, for example when running out of memory. When failing functions are called by others, the failure cascades until the whole program terminates.

Failure means that all resources are released and return values become zero. Often there will be no opportunity to do anything with those zero values, however, as failures cascade and cause the caller to fail and then the caller’s caller and so on. Mutable arguments are left unaffected on failure too. You can manually fail like so:

import "std/core.s"

def always_fail(effect edit console CLI)
    print "we are failing"
    fail "we failed!"

def main()
    CLI = edit console()
    always_fail()
    print "this line is never printed"

Propagating failures is done in the spirit of writing concise but well-controlled code. Don’t care about failures. Unless you must. That is, assume correct execution, as if you were scripting, and only handle failures at places where they can be handled safely or where they would be critical. If it is important to ensure that a function does not fail up to a certain point, place the debug::nocatch() assertion. There are various other compiler assertions too, which are covered later.

import "std/core.s"

def main()
    CLI = edit console()
    try x = alloc KB 4  # buffer of chars
    try x[0] = char "a" # still needs a try for buffer elements - though checks can be optimized away
    try print x[0]
    print "this must run at all costs"
    debug::nocatch()    # error if without 'try' on all PREVIOUS calls that could fail
    print x[10000]      # allowed to fail now
    print "this will never run due to out of bounds error"

There are certain places in code where you may want to recover from call failures, for example by calling the same code again for improved user input, or by falling back to some secondary functionality. This is achieved with the try keyword. That parses an expression without stopping at the first failing functions, if any. Stressing again: this intercepts the first error of an expression not a whole block of code.

Finally, returns from failing functions are just zero-initialized, but try has a boolean outcome that shows whether an error is intercepted. Below is an example that safeguards against failing allocation.

import "std/core.s"

def vector(nat size)
    return float[].alloc size

def main()
    CLI = edit console()
    if not try v = vector pow(1024,6)
        print "failed to allocate"
    print(len v, " numbers allocated\n")

Since failure is a fast abstraction in smoλ, it is also the implementation mechanism for trying to produce next values. Below is an example. for loops are syntactic desugar to something similar.

import "std/core.s"

def main()
    CLI = edit console()
    it = range of 5
    while try i=next it # equivalent to 'for i in range of 5'
        print i

iterators

Warning: This subsection is about creating your own iterators and can be skipped in the first reading.

We can now full grasp the concept of iterating across some data structure. Recall that this is done like below. The expression after in is evaluated first and forms the iterator that we are traversing, whose values are assigned to i. This is equivalent to the previous section’s last example; the for loop is converted into a while try.

import "std/core.s"

def main()
    CLI = edit console()
    for i in range of 10
        print i   # prints 0,1,2...,9

However, iterators do not apply arbitary functions but instead employ the get(data, nat index) function that overloads the data[index] operator for indexes that are natural numbers. In truth, the range ieration examples is equivalent to the next one.

import "std/core.s"

def main()
    CLI = edit console()
    iterator = range of 10
    hidden_index = mut 0
    while try i=iterator[hidden_index]
        hidden_index = hidden_index+1
        print i   # prints 0,1,2...,9

If the outcome of the get is a pointer, it automatically dereferenced so that you can readily iterate across buffers, like below.

import "std/core.s"

def main()
    CLI = edit console()
    for i in [1,2,3]
        print i

The hidden index can be mutated and used to keep track of the iteration state, even when the iterator itself is constant. But you can always skip it. For example, next is a program that keeps the iteration state inside the data.

import "std/core.s"

def solution(mut nat x, mut nat y)
def get(edit solution s, nat) # skip the iteration index argument
    s.x = (s.x+1)/2
    s.y = (s.y+1)/2
    if s.x==s.y fail "converged"
    if s.x>s.y return s.x-s.y
    return s.y-s.x

def main()
    CLI = edit console()
    sol = solution(mut 32, mut 19)
    for diff in sol 
        print nn "difference "
        print diff
    print sol.x

defer

Warning: Deferred execution is mainly useful for safe resource handling when unsafely creating new types of resources. This can be skipped.

You can defer code blocks to run later. The “later” part is ideally the end of the current function, but smoλ may postpone it further to accommodate resources (e.g., buffers) that are still in use. That is, defers are returned alongside function data they are refer to, and are called at the calling site. The compiler complains if some but not all variables involved in a defer block are returned.

Defer blocks cannot have any return statements or unhandled errors; explicitly wrap all potentially erroneous function calls in try. Conversely, their eventual execution is guaranteed, even if errors are created in the interim. Below is a simple example.

import "std/core.s"

def main()
    CLI = edit console()
    defer
        print "third"
    print "first"
    print "second"

Defers can be forcefully executed while invalidating a structure. This is done with the del keyword. For example, this is typically used to close resources like open files and processes.

import "std/core.s"
import "std/io.s":process as process

def main()
    CLI = edit console()
    proc = mut process::read "echo \"hello world!\""
    del proc # runs the process's defer, which waits for completion
    print "bye!"

catching errors

The compiler::catch() function provides the means of retrieving an error code intercepted by try statements. This function creates an error itself if it fails to find an error. To avoid confusion, the compiler just mandates that you should wrap the catch function inside a try of its own. This way, you can obtain the error and check that it exists simultaneously.

import "std/core.s"
import "std/io.s" as io

def main()
    CLI = edit console()
    try print 2*3-20 # nat cannot become negative
    if try error = compiler::catch()
        print "cannot substract two nat numbers and obtain a negative result"

The same function also clears the intercepted error code so that the next call captures only subsequent messages. Caught errors can be compared for equality and converted to strings per cstr error.

Errors are not retrieved when intercepted within called functions. But, importantly, they are obtained from deferred statements triggered by del. The next snippet demonstrates how to clear errors and check on them.

import "std/core.s"
import "std/io.s":process as process

def bye_error()
    fail "bye!" 

def main()
    CLI = edit console()
    proc = mut process::read "echo \"hello world!\""
    try bye_error()
    del proc

    if try error = compiler::catch()
        print cstr error # prints 'bye!' if no process error
        fail error       # can fail with error codes too

allocator effects

Where effects are the most useful is overloading operations that require external resources. The next example demonstrates this using vectors from the corresponding standard library section. The FLOATS effect is used to pass a shared preallocated memory buffer to all needed vector construction. Effects do not propagate outwards, so you can only look at function signatures to know which effects can be declared. For example, one of the possible additions involving vectors, which is used by the + operator, is declared as add(effect edit vec_allocator allocator, vec v1, vec v2).

import "std/core.s"
import "std/sci.s"

def main()
    CLI = edit console()
    FLOATS = edit arena float[].alloc 128
    v1 = vec 10 # vector of 10 elements
    v2 = vec 10
    v1[0] = 1.0
    v2[0] = 2.0
    result = v1+v2
    print result[0]

functors

We have so far seen a lot of details about smoλ’s type system. However, there is one final piece missing that brings a lot of dynamism in programs while adhering to safety guarantees. That is, ways of passing functions around as dynamically determined arguments.

This way of calling different variations not the default, because it can incur overheads for each function call compared to direct-to-the-metal code. That said, such overheads can often be ignored compare to the actual complexity of practical code.

The means of creating placeholder arguments that allow for dynamically provided functions is by changing its type to a functor, that is, an expression of the form input_type->output_type, as demonstrated in the example below. This lets the implementation prepare for functions of the required input and output characteristics that may be only encountered later. The shape of such functions will always be the required one. It is assumed that any such functions could fail when called, and their effects will not be automatically gathered. Furthermore all their arguments are immutable.

The example below also uses type inc to retrieve the functor type of the function inc, as well as the compiler::call builtin function to call a functor with given arguments. The retrieval into a factor should be unique.

import "std/core.s"

def two(nat->nat c)
    one = c.compiler::call 0
    return c.compiler::call one

def inc(nat x)
    return x+1

def main()
    CLI = edit console()
    print two(type inc)

Here is a different example.

import "std/core.s"

def pair(nat, nat)
def least(nat[] numbers, pair->bool order)
    ret = mut numbers[0]
    for number in numbers
        if call order(number,ret) ret = number
    return ret

def min(nat x, nat y)
    if x<y return true
    return false

def main()
    CLI = edit console()
    print two(type inc)
    print least([5,4,1,3,2], type min)

A final type resolution feature is that you can specialize on argument types as you would by providing comma-separated arguments vias the <...> notation. This is demonstrated below, where different verions of the add function are retrieved in different situations to extract functors based on arguments.

import "std/core.s"

def call_one(nat->nat->nat x) 
    return x.compiler::call 1

def add(nat x, 0|1|2 y) 
    return x+compiler::value y

def add(nat x) 
    if x==0 return compiler::abstract type add<nat,0>
    if x==1 return compiler::abstract type add<nat,1>
    if x==2 return compiler::abstract type add<nat,2>

def main()
    CLI = console()
    x = call_one type add<nat>
    print x.compiler::call 5 # prints 6

debugging tools

Warning: This subsection covers debugging tricks and is better suited for advanced readers. You can skip it when working in small projects.

So far there was mention of compiler::skip(), compiler::catch(), compiler::value(expression) and debug::nocatch() that let code interface with the compiler to an extend.

There are some more mechanisms that help inspect programs or grant access to internal compilation state that can be analyzed for debugging.

Foremost of debugging tools is debug::print(expression). This runs an expression, prints its return at compile time, and returns its value. It works this way so that it can be effortlessly interweaved in code. Do note that you can use it with literal types to print messages too. For example, one pattern usable for debuggining is the following.

import "std/core.s"

def main()
    CLI = edit console()
    s1 = str "s1"
    s2 = str "s2"
    debug::print type "--- main ---" # prints '"--- main ---"' at compile time
    s = debug::print (s1,s2)         # prints 'const str, const str' at compile time
    print s # ERROR due to undefined print, but the above still prints

Finally, assert that a specific point in a function does not lie within a loop or condition with the debug::branchless() check. Or, if you have set up VSCode or another means of accessing the language service, simply check on the satements by hovering over the if. This asserts that there is no conditional compilation going on, with the compiler creating an error otherwis, and can be used to ensure that conditions not accidentally evaluated at runtime. The next example uses it to verify that there is no runtime overhead from a particularly complicated comparison - this is skipped because the difference in the types of a and b’ suffices to make a judgement, even if enums usually require runtime checking.

import "std/core.s"

def test1(nat a, nat b, "one"|"two")
def test2(float a, float b, "one"|"two")

def main()
    CLI = edit console()
    a = 1
    b = 2.0
    c = "one"
    test = (a,b,c)
    if not test is test1|test2
        debug::branchless()
        debug::print type "invalid data" # literal type to print the message instead of 'cstr'

bounded compute

Warning: This subsection touches on the theory of bounded computations and will be expanded upon once some alternate language runtimes are introduced.

Much much earlier in this document, recursive functions were discussed. With full knowledge of smoλ’s abilities, it can now be asserted that it is not meaningless to guarantee a return statement type.

Even infinite recursive loops might be merely unbounded due to some termination failure. In fact, it can be argued that all such loops would be bounded by compute resources and available running time, given that they run on finite computers.

The next snippet exemplifies this by adding a recursion safety mechanism as an effect. This mechanism is still rough, but the language marks all recursive functions as potentially failing. This way, future versions can have platform-dependent error codes for unbounded recursion that exceeds system resource limits.

import "std/core.s"

rec wooo(effect edit range SAFETY, nat i)
    next SAFETY
    if false return blank() # do not return anything
    return wooo(i+1)

def main()
    CLI = edit console()
    SAFETY = range of 14 # recursive depth limit in playground
    try wooo 0
    if try error = compiler::catch()
        print cstr error # prints 'iteration end'

A mechanism that already exists for smoλ programs is that the SIGINT signal, which is produced by the user pressing ctrl+C, requires manual handling. The signal handler is imported from the "std/io.s" module and requires a mutable console CLI effect to listen to the console interrupt. Then, the process::interrupt_point() function creates an error if it is called after such an interrupt. For safety, this function asks for user input on whether the program should be terminated.

import "std/core.s"
import "std/io.s"

rec wooo(effect edit console CLI)
    if false return blank()
    process::interrupt_point()
    print "wooo"
    wooo()

def main()
    CLI = edit console()
    try wooo()
    print "\rthe end" # \r go to the start of the console line to remive ^C

Section 3. Standard Library

lists

Manage growing buffers by packing them into lists alloc function. This is done by calling the list function on a mutable buffer or -preferably- on a reference of a mutable buffer as inthe following example. This may resize the buffer to make room for at least one element.

List elements are accessed like buffers. However, they can be used as allocators by calling the alloc(list, nat size) function, where the default size to create is one. Allocation returns a class instance containin a buffer and constant position, and that can be further cast onto a mutable pointer via an at function; this combination alongside pointer data assignment allows for quick pushing of elements at the end of growing lists.

import "std/core.s"

def main()
    CLI = edit console()
    mem = list ref mut float[]
    (at alloc mem) = 0.1
    (at alloc mem) = 0.1
    (at alloc mem) = 0.1

    mem[1] = 0.2
    print mem[0]
    print mem[1]

By the way, the alloc function can also be called on arenas and circular buffers, that can be similarly used once constructed per mem = arena float[].alloc 3 and mem = circular float[].alloc 3. These structures are more stable than lists in that they do not reallocate; the arena creates an error when out of size, and the circular buffer is an arena that restarts once full.

strings

The standard library provides the str structural type for representing strings by combining character buffers (at least their addresses), an offset within the buffer where the string starts (more stable than using a pointer), its length, and its first character for fast cache-friendly comparisons. The first character is \0 for empty strings.

cstr data are trivially convertible to strings. Below is an example, where printing is also implemented for strings. Theoretically, this extracts the size and first character too, but such data are ignored if not needed.

import "std/core.s"

def main()
    CLI = edit console()
    print str "hello world!"

You can convert string contents to numeric types. This creates errors on failure.

import "std/core.s"

def main()
    CLI = edit console()
    print float "123"

Strings can be copied on a pair of buffer and mutable position inside it char[], mut nat. This structure is used a lot when creating buffers for memory management and is called an arena. In general, arenas are just buffer and position pairs of a specific type. Later we will also use them to store float vector contents.

import "std/core.s"

def main()
    CLI = edit console()
    buf = arena alloc KB 4 # equivalent to buf = (alloc KB 4, mut 0)
    s = buf.copy "hello world!"
    print s

To make code read more naturally, character arenas can be set to a CHARS effect. Recall that effects are arguments that are automatically retrieved from the calling location based on their name.

import "std/core.s"

def main()
    CLI = edit console()
    CHARS = arena alloc KB 4 # effect for placing string data
    s = copy "hello world!"
    print s

Also copy strings on buffers managed by lists, which can also be used as a CHARS effect to manage string allocations. DO use ref for stability, as potential list resizing could invalidate the pointer where the string thinks the buffer starts, and the compiler would correctly point that out and refuse to progress on an erroneous program.

import "std/core.s"

def main()
    CLI = edit console()
    CHARS = ref list mut char[]
    s1 = copy "hello world!" # would have been invalidated if we did not use `ref`
    s2 = copy "hello world!"
    print s1
    print s2

Finally, strings provide the add function as a combination of a CHARS effect to place a concatenated outcome, and two individual strings or cstr. By means of automatically passing CHARS (normal effect behavior) this function be treated as a + operation like below.

import "std/core.s"

def main()
    CLI = edit console()
    CHARS = arena char[].alloc KB 4
    s1 = copy "hello"
    s2 = copy "world!"
    print s1+" "+s2

That said, it is preferred to copy strings onto buffers at consecutive places for more efficient operations. The above string addition is rewritten in the next snippet to use less than half space onto the buffer. Note usage of the literal from via the comma-less syntax sugar for literals. This indicates that the construction should be structurally matched to the variation str(char[] buffer, nat end, "from", nat start). There also exists the variation str(char[] buffer, nat start, "to", nat length).

import "std/core.s"

def main()
    CLI = edit console()
    CHARS = arena alloc KB 4
    start = CHARS.pos
    copy "hello"
    copy " "
    copy "world!"
    print str(CHARS from start)

maps

There are some useful hashing utilities under std/hash.s, which culminate to implementing hashmaps under std/map.s with string-valued and nat-valued keys. These two key types cover most useful scenarios, especially given that the standard library offers the ability can serialize data as strings or -for smaller structures- as bits. Map values can reside on any buffer.

The empty string or zero are stored in the first positions of maps and are considered always-presented. For now, map size is fixed. Place contents and iterate through map keys like below. Map initialization takes any allocated buffer and creates a similarly-sized map.

import "std/core.s"
import "std/map.s"

def main()
    CLI = edit console()
    map = mut strmap str[].alloc 128
    map["hello"] = str "hello world!"
    map["manio"] = str "it's a me, manio."
    print map["hello"]
    print map["manio"]

    it = (map.keys, mut 0)
    while try key=next it
        print key

io

There are several means for textual input and output through the console and the file system. Examples below mostly use cstr arguments, but str arguments are fine too. In the last case, if strings are not null-terminated and the buffer holding them does not have a trailing null character to pretend that they are null-terminated, a copy may be made.

Read a file by opening it and iterating line by line, like below. This needs a char[] buffer -or an allocator on a charcater buffer like an arena- on which to store lines.

import "std/core.s"
import "std/io.s"

def main()
    CLI = edit console()
    f = file::read "README.md"
    mem = char[].alloc KB 4 # max 4 KB chunk size, on char[] by default
    for line in (mem, f)
        print nn "|"
        print nn line
    print "" # only now print a new line

Similarly, create a file for writing like below. Can also delete it, or even defer its deletion to when the file is no longer in use.

import "std/core.s"
import "std/io.s"

def main()
    CLI = edit console()
    f = file::write "tmp.txt"
    f.print "hello world"
    defer 
        dir::remove "tmp.txt"

Above was a first introduction to the dir namespace for directory operations. Below is how to iterate through directory contents. This yields temporarily available strings. Availability means that the contents of those strings may be corrupted even if they remain memory safe.

import "std/core.s"
import "std/io.s"

def main()
    CLI = edit console()
    dir = mut dir::read "."
    for entry in dir
        print(entry, " ")
        if dir::is_file entry print "file"
        else print "dir"

processes

Processes can also be read similarly to files. To begin with, a blocking system process that fails on non-zero error code can be evoked like below.

import "std/core.s"
import "std/io.s"

def main()
    CLI = edit console()
    success = try system "echo \"hello world!\""
    print success

One can also open and communicate with running processes similarly to files.

import "std/core.s"
import "std/io.s":process as proc

def main()
    CLI = edit console()
    process = proc:process "ls"
    buf = arena char[].alloc KB 4 # example with growing position
    for line in (buf, process)
        print line

Equivalently, manually release the process to wait for its conclusion. As resource release code intercepts erroneous termination with try, check on this with compiler::catch(). To propagate or otherwise handle the intercepted errors, use a pattern like below.

import "std/core.s"
import "std/io.s":process as proc

def main()
    CLI = edit console()
    process = proc:process "ls"
    del process
    if try error = compiler::catch()
        fail error # can fail on error codes too

random

Retrieving random numbers can be done from the namesake standard library module. There are two types of random numbers available out-of-the-box: splitmix64 for faster computations with only 64-bit random state and Xoshiro256plus for 256-bit random state that is better for longer random sequences. Neither of these is cryptographically secure.

The splitmix64 function acts on a mutable nat state to produce a next random nat number, or -if no argument is provided- creates a mutable seed that uses the system clock as a source of entropy. Here is an example.

import "std/core.s"
import "std/rand.s"

def main()
    CLI = edit console()
    seed = mut splitmix64()
    print splitmix64 seed 
    print splitmix64 seed

Xoshiro256plus is meant to compute random numbers in the range [0,1] and is recommended for long-running programs. Use this unless you know what you are doing, or need cryptographically secure random numbers (these are NOT sscure). Initialize it per Rand() and call next to retrieve next random values.

import "std/core.s"
import "std/rand.s"

def main()
    CLI = edit console()
    rand = mut Rand()
    print next rand
    print next rand

mini and bits

Warning: This subsection covers low-level functionality that may not be immediately useful. It can be skipped.

Here are some concepts about bitwise manipulation and type size awareness.

Bitwise manipulations of nat data are made available from the standard library’s core. To ensure that semantics are not mistaken, you need to convert to and from a bits data structure. For example, here is a (non-cryptographically-secure but usable) hash function for strings. Floats and ints can also be converted to bits.

def hash(str k, nat size)
    h = mut 5381
    iter = range of len k
    while try i = next iter
        h = h.bits().lshift(5).nat() + h + nat k[i]
    return h.mod size

Sometimes, it is useful to store arithmetic data in more compressed formats. The default builtin types (float, nat, int) consumed 64 bits of storage but there are some variations, namely float32, nat32, nat16, nat8, int32, int16, int8.

The std/mini.s namespace provides conversions from 64-bit to narrower types. To ensure semantic safety and no information loss, the standard library does not provide arithmetic operations for these operations, and they should be converted to and from the more expressive types. Errors are created if the conversion would overflow in number magnitude.

The same namespace also provides a string variation that is stored in buffers of up to 65535 length and elements, hereby consuming 12 instead of 25 bytes per element when organized in a string buffer.

import "std/core.s"
import "std/mini.s" as mini

def concat(mini:str[] buff)
    mem = arena char[].alloc KB 4
    start = mem.pos
    for element in buff
        mem.copy mini:unpack element
        mem.copy " "
    return str(mem.buf,start,mem.pos)

def main()
    CLI = edit console()
    buff = mini:str[].alloc 6
    debug::print buff # print the buffer type during compilation
    buff[0] = mini:str "hi"
    buff[1] = mini:str "my"
    buff[2] = mini:str "name"
    buff[3] = mini:str "is"
    buff[4] = mini:str "manios"
    buff[5] = mini:str concat buff
    for element in buff
        print mini:unpack element

vectors

The standard library provides the means of conducting scientific computations via matrix and vector arithmetics. Everything is stored in underlying float[] buffers that can be set as allocator effects and imported from the std/sci.s file. Let us start with a quick preview of a vector. Vector elements can be set and read as if working on buffers, but additional operations are provided.

import "std/core.s"
import "std/sci.s"

def main()
    CLI = edit console()
    v = vec [1.0, 2.0, 0.0, 0.0, 0.0]
    print ("(sum, mean, std) = (", "")
    print (sum v, ", ")
    print (mean v, ", ")
    print (std v, ")\n")

Vectors can be placed on arena buffer-position pairs, as well as on circular buffer constructs that restart from the starting position after the end.

import "std/core.s"
import "std/sci.s"

def safe_main()
    allocator  = ref circular float[].alloc 200 # used by vector operation effects
    allocator2 = ref circular float[].alloc 200 # useless 
    v1 = vec 10
    v2 = vec 10
    v1[0] = 1.0
    v2[0] = 2.0

    v = mut vec 10
    for i in range 5
        v = 2.0*(v1+v2+v)
        #v = allocator2.mul(2.0, v1+v2+v) # THIS WOULD CREATE AN ERROR
    print v[0]

def main()
    CLI = edit console()
    try safe_main()
    if try error=compiler::catch()
        print cstr error

An extension of vectors are matrices. These are defined with similar patterns, including the consumption of a whole float[] buffer to be split into a number of rows. Matrix multiplication and matrix-vector multiplication are also implemented.

import "std/core.s"
import "std/sci.s"

def main()
    CLI = edit console()
    FLOATS = mut new() # allocate to new memory whenever needed

    a = mat [
        1.0, 0.0, 2.0,
        0.0, 3.0, 1.0
    ].any 2 # create a tuple of the (data,2) without parentheses

    a[0,0]=1.0

    x = vec [1.0, 2.0, 3.0]
    print nn "a*x"
    print a*x

    u = vec [1.0, 2.0]
    print nn "u*a"
    print u*a

    b = mat [
        1.0, 2.0,
        3.0, 4.0,
        5.0, 6.0
    ].any 3

    print "a*b"
    print a*b

matrices and graphs

We are not yet done with the science stuff. And, yes, there is more to matrices, namely sparse matrices.

Sparse matrices are used when there are a lot of zeroes, For example, if we consider a graph (a collection of vertices linked through edges - like a social network where the vertices are the users and the edges their friendships), it can be analyzed by organizing its edges into a square adjacency matrix. In the simplest case, the matrix’s elements would be 1.0 at position (row,col) if the edge between row and col exists, and zero otherwise. Edges can also be weighed.

Sparse matrices in smoλ are usually stored in coo format that is speedy for linear algebra and graph algorithms. This format is stored on buffers of the tuple def sparse_element(nat row, nat col, float value). Sparse matrices do implement multiplication with dense matrices, as well as with vectors from bot the left and the right side. Similar to dense matrices.

There are a number of operations available for the analysis of graphs stored onto sparse matrices. These include graph filters that allow the “diffusion” of vertex weights through the graph structure. An example is presented next, where ppr (standing for personalized PageRank - the algorithm Google introduced for analyzing web hyperlinks) is the most well-known filter. 0.9 is a diffusion parameter in the range [0.0,1.0] that determines how far away the initial vertex values on p0 should be diffused.

In the example below, notice use of the matrix union, which automatically applies whatever matrix type suites the provided data. This makes code more portable if you want to switch things up in the future.

import "std/core.s"
import "std/sci.s"

def main()
    CLI = edit console()
    m = compt new().graph:normalize matrix [
        (1,1, 1.0),
        (2,2, 2.0),
        (1,2, 1.0)
    ].any(3,3)
    FLOATS = mut new()
    p0 = vec [1.0,2.0,3.0]
    result = graph:ppr(0.9).graph:filter(m, p0)
    print nn "iterations: "
    print result.iter
    print result.p
Info: more graph algorithms and details will be provided in the future, likely as a separate tutorial.

graphics

Graphics for games of desktop applications are supported via raylib. Below is an example that moves sevral circles around in a window.

import "std/core.s"
import "std/sci.s" as sci
import "std/graphics.s" as graphics

def Circle(float _cx, float _cy, float _vx, float _vy, float _radius)
    cx = mut _cx
    cy = mut _cy
    vx = mut _vx
    vy = mut _vy
    radius = mut _radius
    return (cx,cy,vx,vy,radius)

def process(mut Circle ptr _self, float dt)
    self = compiler::deref _self
    self.cx = self.cx + self.vx * dt
    self.cy = self.cy + self.vy * dt
    if self.cx - self.radius < 0.0
        self.cx = self.radius
        self.vx = sci::abs self.vx
    if self.cx + self.radius > 800.0
        self.cx = 800.0 - self.radius
        self.vx = 0.0-(sci::abs self.vx)
    if self.cy - self.radius < 0.0
        self.cy = self.radius
        self.vy = sci::abs self.vy
    if self.cy + self.radius > 600.0
        self.cy = 600.0 - self.radius
        self.vy = 0.0-(sci::abs self.vy)
    _self = self

def draw(Circle self, edit graphics:Window win)
    white  = graphics:Color(255, 255, 255)
    teal   = graphics:Color(0,   200, 180)
    shadow = graphics:Color(0,   200, 180, 60)
    pos = (self.cx, self.cy)
    win
    .graphics:circ(self.cx + 4.0, self.cy + 4.0, self.radius, shadow)
    .graphics:circ(pos, self.radius, teal)
    .graphics:circ_line(pos, nat self.radius, 2, white)

def main()
    win = graphics:Size(800.0, 600.0).graphics:Window "Circles"

    N = 1000
    circles = Circle[].alloc N
    for create_circle&& in circles
        i = float compiler::for_counter() # builtin way of enumerating
        create_circle = Circle(400.0, 300.0, 200.0-i, 160.0+i, 30.0)

    while graphics:is_open win
        dt = graphics:dt()
        for proc_circle&& in circles # mutable pointer
            proc_circle.process dt
        frame = graphics:draw win
        win.graphics:clear graphics:Color(20, 20, 60)
        for draw_circle in circles
            draw_circle.draw win
        del frame

process and web

One part of smoλ’s design philosophy is that programs should be able to communicate with other programs to form an ecosystem in each machine. For example, why should someone embed CURL and corresponding security features in their executable when they can have the battle-tested command line tool at their disposal?

When your application can afford it, it is actively encouraged to communicate with operating system processes when the latter are longer-running. Then, system error codes are propagated as normal failure. Of course, this vision requires a robust way of communicating with the operating system. This is done via the process namespace under the std.io.s include like below that is directly copied from the library. This dynamically constructs a command string on a buffer and uses it to run a system process. The deferred process deletion can be force-executed via del, thus waiting for the process’s end. This may leave a notification of non-zero exit code, which can be caught afterwards to check for successful termination.

Warning: System commands are not available in the online playground.
import "std/core.s"
import "std/io.s"

def run(cstr|str command)
    proc = mut process::open command
    del proc # force resource deallocation = end the process
    if try error = compiler::catch()
        print cstr error

def main()
    CLI = edit console()
    CHARS = arena char[].alloc 256
    path = "./tests/passing/"
    copy "./smoll "
    copy path
    test_dir = dir::open path
    for entry in test_dir # do not move the position
        if not entry.ends_with ".s" continue
        command = CHARS.buf.str endpos copy_null_terminated(local CHARS, str entry)
        print command
        run command

The next example demonstrates usage of CURL via the web namespace in the same include. You might recognize this example from the language’s front page. The main usage pattern consists of passing a string or cstr url into the web:get function to create a temporary file. The function returns the remporary file’s path. Of course, programmatic integration of libCURL functionality is still viable via C code injection, but is left outside of the standard library for now.

The iterator defined over files uses a line(CHARS, f) function undeneath, where the first argument is an arena or circular buffer effect on which to read line contents. Of the two, the circular buffer version always positions new contens at the start of the buffer. Reading lines consumes at most the rest of available space, and if a new line is not reached until that point the "\n" character may not be present at the line’s ending. Preffer the circular buffer reader for temprarily read lines, as it can go through arbitarily-sized files.

import "std/core.s"
import "std/io.s"

def README = "https://raw.githubusercontent.com/maniospas/smoll/refs/heads/main/README.md"

def main()
    CLI = edit console()
    CHARS = circular alloc KB 4
    f = edit file::open web:get README
    size = mut 0
    for line in f
        size = size+len line
    print(size, " bytes downloaded\n")

You can always set a custom character buffer allocator instead of automatically passing a CHARS effect, for example by writing for line in (circular alloc KB 4, f) to pass the allocator-file pair as explicit arguments to the iterator. This is useful when interweaving other kinds of string manipulation code.