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
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!"
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!"
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
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
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
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:
range of 10 becomes range(0,10) range of (1 to 10) becomes range(1,10) range of (1 upto 10) becomes range(1,11) range of (2 len 10) becomes range(2,12)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:
x and complicate_check(y) does not run the function if x is false, whereas x or complicate_check(y) does not run the function if it’s true.x = false and y sets x to be a zero-initialized copy of y (zero initialization is always valid), whereas x = true or y set the result to be not y given that a function not exists.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
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:
Step 1. All functions are parsed, where recursive ones stop at their first return statement. Up to that point, everything can only see previous functions, which means that you need to encode the recursion stopping conditions.
Step 2. The rest of the recursive definitions are parsed. These now have access to the whole file’s types.
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()
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()
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)
|, 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
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
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.
type expression within type parsing run an expression and retrieve its type.->output_type after an ambiguous function call to select the call variation producting the desired output_type. For example, Num 1 includes -among other functions- all casts from the natural number 1 to various number formats. So Num 1->type x forces the function cast that retrieves exactly the type of x.x (that can be an expression) using value is type x.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
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"
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 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.
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
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.
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
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
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!"
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
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]
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
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'
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
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.
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)
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
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 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
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
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
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
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
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
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.
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.