Tutorials - The language

Learning ArkScript: the basics

In ArkScript, there are two main rules:

  • The first rule is that (a b c ...) is a function call, a is the function with b c ... being the arguments.
  • The second rule is the only exception to the first rule: when declaring functions like (fun (arg1 arg2 ...) body), the definition of the argument block isn't considered as a function call.

To sum it up: everything is a function call, except the definition of the arguments block of a function.

Creating variables

In ArkScript, there are 2 types of variables: mutables and immutables. The immutable variables can't be modified or redefined, while mutable variables can.
The following three keywords are important when talking about variables:
  • let defines an immutable variable
  • mut defines a mutable variable
  • set changes the value of a given mutable variable (will throw an error on immutable variables)


Example:
(let a 12)  # immutable variable
(let a "hello")  # cannot redefine constant
(set a "world")  # cannot modify constant, will throw an error
 
(mut b [12 42 64])  # mutable variable
(mut b "hello")  # no problem, the operation is allowed
(set b "ArkScript is cool!")  # no problem, operation is allowed on mutables

Comments

As you have seen in the example above, we can write code that won't be executed, using # text. This is a comment, only for the developer, and will be totally ignored when compiling and executing.

Program structure

An ArkScript program is a collection of blocks. A block (function arguments...), thus, those are valid programs:

(print "hello world")
(let a 12)
(let foo (fun (a b)
    (+ a b)))
(print (foo a a))

Multiple blocks can be put into one by using the begin construction:

(begin
    (let a 12)
    (print a)
    (let b (* a 2))
    (print b))
 
# and can be accessed from outside, begin doesn't create a scope
(print a " " b)

(begin) and {} are synonyms.

Basic Input/Output

User interactions are a must have in a programming language. To achieve such interaction in a shell (the big black windows where our code is running), we have what we call IO or input/output, through print and input. One can write text to the shell, the other can prompt the user and retrieve what they wrote.

Example: (print "hello" " world"), will print hello world.

The print function won't put spaces between each element printed, thus we have to do it ourselves.

(let a (input "what is your name?"))  # will print what the user wrote,
(print a)                             # after having validated by pressing Enter
                                      # The prompt is optionnal, (input) will
                                      # also work on its own.

Conditions

In any programming language, it's useful to control the program flow, to be able to give the user multiple choices (attacking an ennemy, befriending it, spying on it...) and those things are achieved through conditions.

Constructing a condition is done like so:

(if condition then else)

The else bloc is optionnal.

Examples:

(if (= a 12)
    # then
    (print "a is 12")
    # else
    (print "a is not 12"))
 
(if (and (< a 12) (> b 14))
    (print "a is < 12 AND b is > 14"))

The then and the else parts can be composed of multiple functions by using the begin construction.

Loops

Giving the user a choice is a thing, but repeating an action is another that is very useful in a program as well. For example, if we need to compute the sum of values in a list, we would need to loop over the values of the list. In video games, we would need loops to generate waves of ennemies.

Loops in ArkScript are created by using the keyword while.

Example:

(import "random.arkm")
 
# continue must be a mutable for us the be able to modify it
(mut continue true)
(while continue {
    (print "hello")
 
    # 10% chance of stopping the loop
    # random returns a number in range [0, 32768[
    (if (< (/ (random) 32768) 0.1)
        (set continue false))
})
 
# another example using conditions
(mut i 0)
(while (< i 10) {
    (puts i " ")  # won't put a \n at the end of the content
    (set i (+ 1 i))
})

Functions

Functions are a tool to factorize code, to follow the DRY (don't repeat yourself) principle. Who would want to write 10 times the same 100 lines when they can use a function and call it 10 times inside a loop?

Note: ArkScript was particularly optimized to deal with function using few arguments, thus encouraging code reuse and code split into functions.

A function is composed of 2 parts: the argument lists and the body:

(fun (a b c) (print a b c))

(a b c) is the argument list, the print bloc is the body.

The value returned by a function is the last evaluated value in the body, if none, nil is returned.

Example:

(let foo (fun (a b) (begin
    (print "function got: " a " " b)
    # return value:
    (+ a b))))
 
(print (foo 12 14))  # 26

You can also use quoting to quickly create anonymous functions taking 0 arguments, useful to make callbacks on the run:

(let i_want_a_callback (fun (cb) {
    (print "I am a function")
    (cb)
    (print "I am still here")
}))
 
# would work, but a bit long to write
(i_want_a_callback (fun () (print "hello world")))
 
# using the quote shorthand '
(i_want_a_callback '(print "hello world"))
# or by using the keyword
(i_want_a_callback (quote (print "hello world")))

Also, we have a set of builtins functions in the language, available without importing anything ; for example print or input, which we used before. Note that builtins must be called, you can't do things like (let my_print print), or (let my_tail tail), otherwise it will result in an error because those functions are special.

Closures

Closure, or function closure, is a way to implementing lexically scoped name binding1. It stores a function along with an environment, explicitly mapped with specified variables. This allows to reuse and modify captured variables each time the closure is called.

(let make_closure (fun (name age) {
    (let coolness_factor 12)
 
    # here, we return a closure!
    (fun (&name &age &coolness_factor)
        # each time it will be called, it will display the captured variables
        (print name " " age " " coolness_factor))
}))
 
(let closure (make_closure "Pietro" 42))
(closure)  # prints Pietro 42 12

Closures capture variables explicitly in their arguments' list, by prefixing them with &. We can access the captured fields through the closure.field notation, in a read only way:

(print closure.age)  # 42
(print closure.coolness_factor)  # 12
 
# we can print closures and have their fields and values displayed
(print closure)  # prints (.name=Pietro .age=42 .coolness_factor=12)
 
(let make (fun (a)
    (fun (&a) ())))
 
(let foo (fun () (print "bar")))
 
(let closure_bis (make foo))
# we can also call captured functions
(closure_bis.a)  # prints bar

Finally, you can modify the closure content when using it from the inside, through itself or its captured functions:

(let make (fun (name age) {
    (let set-age (fun (new)
        (set age new)))
 
    (fun (new-name &name &age &set-age)
        (if (not (nil? new-name))
            (set name new-name)))
}))
 
(let egg (make "egg" 1))
(print egg.age)  # 1
(egg.set-age 2)
(print egg.age " " egg.name)  # 2 egg
(egg "not an egg")
(print egg.name " " egg.age)  # not an egg 2

Importing code

Putting code in multiple files is pretty to make it reusable and more maintainable.

In ArkScript, imported code is copied from the specified file into the current one, with a guarantee: circular includes are detected and prevented, making execution always possible even if you include a lot of files.

Files are imported by doing so: (import "myfile.ark"). The path to the target file is relative to the source file, not to the main executed file.

The only exception about paths in imports is when you import an ArkScript module, ending in .arkm. Those files are either in the standard library, thus you can just write their name and ArkScript will find them, or they must be alongside the final executed file.

When importing files from the standard library, you don't need to write the path to the library folder, just the path of the file in it. For example: (import "String.ark") will work without problems.

Running functions asynchronously

Available in ArkScript v3.4.0

Functions can be pretty useful to define tasks, but sometimes we don't want to block our program to wait for a task to finish, for example fetching an HTML page. With ArkScript async/await you can submit the request and do other work that's waiting while the HTTP request is being processed.

For a simpler example, let's calculate the sum of numbers in a list:

(let size 1000)
(let data (list:fill size 1))  # create a list of a thousand 1's
 
(let sum (fun (a b src) {
    (mut acc 0)
    (while (< a b) {
        (set acc (+ acc (@ src a)))
        (set a (+ 1 a))
    })
    acc
}))
 
(let task (async sum 0 size data))
 
# do something else...
 
# now we need the result, let's retrieve it
(print (await task))

async can launch any function in a separate thread, given a set of arguments for the functions. Then it will work without you having to do anything, and you can retrieve the result by using await. It will check if the function is done, otherwise wait for it (blocking the thread of the caller) and return its result.

Using macros to manipulate code at compile time

Available in ArkScript 3.1.0

A macro is a rule or pattern that specifies how a certain input should be mapped to a replacement output. Applying a macro to an input is name macro expansion2.

In ArkScript, there are 3 different types of macros:

  • conditions: !{if condition then else}
  • constants: !{my_const value}
  • functions: !{my_function (foo bar) body}

Constant macros are just associations identifier to value, the value being whatever you want (even another bloc of code, for example (let b 12)). The code is scanned and when such macro is found, it's applied wherever possible.

Macros' scopes are tied to the bloc in which they are defined. At the end of said bloc, the macros defined in it are destroyed. Note that a macro defined a in bloc, which includes other blocs, will be available in all the other sub-blocs.

Named macros can be undefined by using !{undef name}.

!{a 12}
 
{
    (print a)  # will print 12, it works!
 
    !{a 1}  # we can shadow macros by defining other macros with the
            # same name in sub-blocs
    (print a)  # 1
 
    !{undef a}
    (print a)  # 12, because we undefined the nearest version of a
}
 
(print a)  # a is still 12 here

Condition macros may only work on compile time expressions, using only other macros. For example:

!{foo 12}
!{bar 14}
 
# the condition being true will result in the print being inserted in the program
# while the (let a) will be deleted
!{if (and (= foo 12) (= bar 14))
    (print "foo is 12 and bar is 14")
    (let a (+ 12 14))}

In a condition macro we can define other macros and use other conditions macros:

!{a 12}
!{if (= a 12)
    !{b 14}
    !{c 13}
}
 
(print b)  # prints 14
(print c)  # compilation error: unbound variable c
           # c is unavailable here because it was never defined

Function macros are evaluated recursively, thus they can call themselves or other macros, and use condition macros. A particularity is that their arguments can ArkScript code blocs, such as a (let a 12) or even complexe code blocs like { (mut i 0) (while continue { (print "hello") (set i (+ 1 i)) (if (> i 12) (set continue false)) }) }.

Those macros can use a magic pattern ...args (args being the name of the argument, you can use whatever you want) as the last argument to tell the compiler that the macros can take any number of arguments. This is called varargs or variadic arguments.

!{foo (a b) {
    (print a " " b)
    (let c (+ a b))
}}
 
(foo 1 2)
(print c)  # prints 3
 
!{bar (a ...args)
    (print a " " args)}
 
(bar 1)  # prints 1
(bar 1 2)  # prints 1 [2]
(bar 1 2 3)  # prints 1 [2 3]

Here is a more complex example implementing the thread macro. The first argument is the data, then each function is applied onto it, one after another. It allows us to write more readable code, instead of the ugly (read-string (slurp (io:file (io:resource filename)))).

!{-> (arg fn1 ...fn) {
    !{if (> (len fn) 0)
        (-> (fn1 arg) ...fn)
        (fn1 arg)
    }
}}
 
(let filename "hello.json")
(let io:resource (fun (file) {
    (print "io:resource")
    file
}))
(let io:file (fun (name) {
    (print "io:file")
    name
}))
(let slurp (fun (a) {
    (print "io:slurp")
    a
}))
(let read-string (fun (a) {
    (print "read-string")
    a
}))
 
(print (-> filename io:resource io:file slurp read-string))
# it will print:
#   io:resource
#   io:file
#   io:slurp
#   read-string
#   hello.json

Here is the list of the available compile time functions, to work with macros (in macros):

  • Comparison operators: =, !=, <, <=, >, >=
  • Chaining conditions / inverting them: not, and, or
  • Working on lists: len, @, head, tail

We also have a few predefined macros to work on ArkScript code and ease code generation. For example, one can generate a new symbol using (symcat symbol value-or-expression), or count the number of arguments of a function with (argcount function-name).

  • symcat: generate a new symbol from a given symbol and a value or expression. (- a 1) with a a constant macro (or macro argument) is valid.
  • argcount: retrieve at compile time the number of arguments taken by a given function. The function must have been defined before using argcount, or must be an anonymous function: (argcount (fun (a b c) ())), (argcount my-function).