Skip to content

Syntax

Programs in Yona consist always of evaluation of a single expression. In fact, any Yona program consists of exactly one expression.

The syntax is intentionally very minimalistic and inspired by languages such as SML or Haskell. There are only a handful of keywords, however it is not as flexible in naming as Haskell for example.

Yona programs have the ambition to be easily readable. Custom operators with names consisting of symbols alone are not that useful when reading a program for the first time. This is why Yona does not support custom operators named by symbols only. Supported operators are listed here.

Yona does not have indentation specific parsing rules, but it does require new line at certain locations, which are noted in each individual expression descriptions.

Comments are denoted by the # character and everything that follows this character until the end of line (\n or \r\n) is considered a comment and ignored.

The source code of a Yona program must be a valid UTF-8 text file.

Terminology

These are some common terms and phrases used throughout these texts explained to a user unfamiliar with functional concepts:

  • Function application: simply function call.

  • Currying: a transformation of functions that translates a function from callable as f(a, b, c) into callable as f(a)(b)(c). Currying doesn’t call a function. It just transforms it. The example uses JavaScript syntax for clarity.

  • Alias: what would be a variable in other languages, but cannot have its value modified during program execution. Once set, this alias, or a name, always contains the same original value.

  • Pattern: an expected shape of a value. If the value "matches" this pattern, then this value can also be deconstructed onto it. For instance a pattern can be a first element of a list and the rest. The first element can be also assigned to an alias (or the rest), provided that the value matched this pattern. Patterns can be nested and serve as a building blocks for describing data structure for matching and extracting values. Patterns may be as simple as an alias - that is also a pattern in a way. An alias will be matched if an only it is a new name, that was not previously assigned, or if it was, then only if the value is the same as the one previously assigned to it.

  • Guard: guards are additional conditions that may be applied to patterns, that otherwise couldn't be expressed using pattern syntax. Typical example might be a function call to determine a type of a value for example.

  • String interpolation: process of substituting values of variables into placeholders in a string. For instance, if there a template for saying hello to a person like "Hello {parson}, nice to meet you!", where the placeholder should be replaced with an actual person's name. This process is called string interpolation.

Functions: definition and application

Functions in Yona are defined in a very short and concise form. They may take arguments, which the function can pattern match on, and one function can be defined using multiple arguments. Function names must start with a lowercase letter, followed by any letters, numbers or underscores.

A simple function to calculate a factorial can be written for example this way:

factorial 1 = 1
factorial n = n * factorial (n - 1)

Each function case must be on a new line. More complex conditions in patterns can be specified in this way:

factorial 1 = 1
factorial n 
  | n > 1 = n * factorial (n - 1)
Which means that there is an additional condition for the n value to be greater than 0. There may be multiple guards for each pattern and they must each be on a new line. A guard starts with the | character, followed an expression that must evaluate to a boolean and finally an = and the expression to evaluate if the pattern matches and the guard is true. Guards are used to express conditions that must be met in order for the pattern to match. A pattern may have none, one or multiple guards, and the first one matching will cause the pattern to match.

Note that function arguments may actually be full patterns, not just argument names. Patterns are described in the section named Pattern Matching.

Yona additionally supports non-linear patterns, meaning that if a pattern contains the same name multiple times, than this name is required to match the same value so that the pattern would match. This can be handy when checking for one value to be present in multiple places/arguments without having to explicitly write a guard that would ensure the equality.

Anonymous functions: aka lambdas

Since functions are first-class citizens in Yona, it is necessary to provide means of passing functions as arguments, and also to define them without giving them a name. The following syntax is used in this case:

\argument_one argument_two -> argument_one argument_two  # lambda function for summing its arguments

A lambda function with no arguments is simply: \-> :something.

Function application: calling functions

Calling a function simply means writing the name of the function and then specifying its arguments. If fewer arguments are provided than the function expects, this is considered a curried call, and the result of such function is a partially applied function, that can be called with the remaining arguments at a later point.

So for example calling a factorial would look simply like this:

factorial 5

Since Yona is a strictly evaluated language, meaning that arguments are evaluated before calling the function (as opposed to a lazy language, where arguments are only evaluated when actually used by the called function), there is one situation to be careful about and that is passing lambda functions of zero arguments as arguments to functions. This would be evaluated before actually calling the function. If there are no side-effects happening in the lambda, it might be perfectly fine, however if the lambda must be passed as a lambda, it needs to be wrapped into another lambda at the call-site, such as for example:

let
    lambda = \-> IO::println :hello
in
    do_something_with \-> lambda  # instead of do_something_with lambda

Then the do_something_with function will obtain its argument as a function and not a result of IO::println :hello function (that would be :hello btw).

Pipes and operator precedence

Since Yona is an ML-style language and, unlike many C-like languages, it does not use parentheses to denote a function application, it can become unclear which expressions are arguments of which function. Take for the following example:

Seq::take 5 Seq::random 10

Is this a function call to the Seq::take function (that is a function take in the module Seq as explained later) with 3 arguments (5, Seq::random and 10) or is it Seq::random 10 supposed to be computed first and then its result used as a second argument to Seq::take?

If the latter, then it can be written this way:

Seq::take 5 (Seq::random 10)

Since any expression can be put into parentheses and be given precedence in evaluation. Another way to write this would be using pipes:

Seq::take 5 <| Seq::random 10

or

Seq::random 10 |> Seq::take 5

These are all equivalent expression and it is up to the programmer's preference to decide which one to use. One nice feature that pipes have is that they can be used on multiple lines, such as:

Seq::random 10
|> Seq::take 5
|> IO::println

let expression: defining local aliases / pattern matching in the scope of evaluated expression

The let expression allows defining aliases in the scope of the executed expressions. Alias represents a name for some value, like a variable, unlike a variable though, its value may not be changed over time. This expression allows evaluating patterns as well, so it is possible to deconstruct a value from a sequence, tuple, set, dictionary or a record directly, for example:

let
    (1, second) = (1, 2)
    pattern     = expression1
in
    expression2

As shown in this example, the let expression consists of two parts. Each line consists of an alias or a pattern on the left side, and the expression to evaluate on the right side. The result of this expression is then matched to the pattern on the left side, or simply assigned to an alias, depending on what is on the left side. The result of this let expression is the result of the expression2 expression. If a pattern on the left is not matched on any of the lines, the whole let expression raises a :nomatch exception. One line can use names defined in previous lines. Every alias/pattern must be defined on a new line.

Note that the order of execution of the alias/pattern lines is not strictly sequential. Considering the example in the documentation homepage, some aliases can be executed as a single batch, in parallel, provided that two conditions are met: * the do not depend on each other (do not use names provided by other aliases in the same batch) * they return a runtime Promise (IO operations, or results of the async function)

When both conditions are met, Yona can safely execute them concurrently, avoiding unnecessary blocking and effectively speeding up the program execution.

do expression: sequencing side effects

The do expression is used to define a sequence of side effecting expressions. This expression is very similar to the let expression in the sense that it allows defining aliases and patterns, however, it doesn't have a separate expression that would be used as a result of this expression. Instead the result of the last line is used as the result.

do
    start_time  = Time::now
    (:ok, line) = File::read_line f
    end_time    = Time::now
    printf line
    printf (end_time - start_time)
end

Note that unlike the case with the let expression, the order of executed expressions is guaranteed to be exactly the same as the order in which they are written. This is the main use-case of the do expression: to allow strict ordering of execution. It does not matter whether an expression returns a run-time level Promise (such as one that can be created by a IO call or by using the async function), the order defined in this expression is always guaranteed to be maintained.

case expression: pattern matching

case expression is used for pattern matching on an expression. Each line of this expression contains a pattern followed by an arrow -> and an expression that is evaluated in case of a successful pattern match. Patterns are tried in the order in which they are specified. The default case can be denoted by an underscore _ pattern that always evaluates as true.

case File::read_line f of
    (:ok, line)       -> line
    (:ok, :eof)       -> :eof
    err@(:error, _)   -> err
    tuple   # guard expressions below
        | tuple_size tuple == 3 -> :ok
        | true                  -> :ok
    _                 -> (:error, :unknown)
end

Same as with function definition patterns, patterns in the case expression can contain guard expressions, as is the case in the tuple pattern in the example above. Patterns with a guard expression will be matched only the guard evaluated to true. Note that each pattern may have multiple guard expression, in which case the first one evaluated to true will match. Guards are tried in the order they are specified in.

if expression: conditions

if is a conditional expression that takes form:

if expression
then
    expression
else
    expression

Both the then and the else parts must be defined. The expression after if must evaluate to a boolean value. Empty sequences, dictionaries or similar will not work! New lines are allowed, but not required.

with expression: resource management

with expression handles resource management within its scope. It initilizes the scope with a context manager that is available within the scope its body.

The syntax looks like this (as alias part is optional):

with contextManager as alias
    bodyExpression
end

The contextManager expression must return a context manager. If no alias is used, then the context manager is not meant to be interacted with directly. An example for this would be an STM transaction, that just needs to be present but there is no need to directly refer to it.

Alternatively, if the alias is specified, as usually case with files, then the file created in the contextManager part of this expression can be directly accessed in bodyExpression.

Example that reads all lines using File::read_lines function:

with File::open "File.txt" {:read} as file
    File::read_lines file
end

with daemon expression: dropping result of a context manager expression

with daemon is a special form of the with expression which behaves exactly the same way in relation to the resource management, but the difference is that unlike the standard with expression, it does not return the result of its body as the result, it returns a () instead. This way, whatever the body is doing, the with daemon expression does not need to wait for its completion, but it returns immediately.

This is particularly useful when accepting socket connections. As soon as the socket connection is accepted, this expression will return () and the execution of its body is moved to the background. This is how one can easily implement concurrent servers in Yona.

Example using Socket::accept function:

let
    addr = "127.0.0.1"
    port = 5555

    accept = \channel ->
        with daemon socket\Server::accept channel as connection
            socket\Connection::read_line connection |> socket\Connection::write connection
        end
in

with socket\Server::channel addr port as channel
    infi (\-> accept channel)
end

module expression

module is an expression representing a set of records (optional) and functions. A module must export at least one function, others may be private - usable only from functions defined within the same module. Records are always visible only within the same module and may not be exported. A module may be defined as a file - in this case, the file must take the name of the module + .yona. Also, see section about module loader for details regarding loading modules.

module package\DemoMmodule 
    exports function1, function2
as

record DataStructure = (field_one, field_two)

function1 = :something

function2 = :something_else
end

Calling functions from a module is denoted by a double colon:

package\DemoModule::function1

Modules must have capitalized names, while packages are expected to start with a lowercase letter.

Module may also be defined dynamically, for example assigned to a name in a let expression:

let some_module = module TestModule exports test_function as
        test_function = 5
    end
in
    some_module::test_function

In this case the name of the module does not matter, as the module is assigned to the test_module value.

with expression: resource management

with expression handles resource management within its scope. It initializes the scope with a context manager that is available within the scope its body.

The syntax lookes like this (as alias part is optional):

with contextManager as alias
    bodyExpression
end

The contextManager expression must return a context manager. If no alias is used, then the context manager is not meant to be interacted with directly. An example for this would be an STM transaction, that just needs to be present but there is no need to directly refer to it.

Alternatively, if the alias is specified, as usually case with files, then the file creted in the contextManager part of this expression can be directly accessed in bodyExpression.

Example that reads all lines using File::read_lines function:

with File::open "File.txt" {:read} as file
    File::read_lines file
end

Packages

Packages are logical units for organizing modules. Modules stored in packages must follow a folder structure which is exactly the same as the package path.

Records

New data structures in Yona can be implemented simply by using tuples. However, as tuples grow in number of elements, it may become useful to name those elements rather than always matching on a particular n-th element. To do so, Yona provides records.

Records are essentially named tuples with names for each element and can be used to refer to a particular element by that name. Records exist within the scope of a module and cannot be imported by or exported to other modules. Modules are meant to provide an interface via functions alone.

A record is defined by its name and a list of field names. Here's an example of a record definition:

record Car = (brand, model, engine_type)

To initialize a new record instance, following syntax should be used:

let
    car = Car(brand="Audi", model="A4", engine_type="TDI")
in
    ...

Note that not all fields are required when initializing record. Uninitialized fields are then initialized with value of () (unit). At least one field must be provided during initialization, though (its value may be unit as well).

Updating an existing record instance (actually creating a new one with some fields changed) can be done using this syntax:

let
    new_car = car(model="A6")  # this is same as Car(brand="Audi", model="A4", engine_type="TDI")
in
    ...

To access a field from a record instance a dot syntax is used:

IO::println new_car.model  # will print A6

Pattern matching on records is very easy as well:

order_car Car(brand="Audi", model=model) = order_audi_model model
order_car Car(brand="Lexus", model=model) = order_lexus_model model
order_car any_car@Car = order_elsewhere any_car  # the whole record_instance is available under name any_car

import expression: importing functions from other modules

Normally, it is not necessary to import modules, as it is often the case in many other languages. Functions from another modules can be called without explicitly declaring them as imported. However, Yona has a special import expression (and as such it returns the value of the expression followed by the in keyword) that allows importing functions from modules and in that way create aliases for otherwise fully qualified names.

import
    funone as one_fun, otherfun from package\SimpleModule
    funtwo from other\package\NotSimpleModule
in
    onefun :something  # expression that is the return value of the whole import expression

Note that importing functions from multiple modules is possible, they just have to be put on new lines. Functions can be renamed using the as keyword.

See the section about module loader for more details regarding loading modules.

raise, try/catch expressions: raising and catching exceptions

Yona is not a pure language, therefore it allows raising exceptions. Exceptions in Yona are represented as a tuple of a symbol and a message. Message can be empty, if not provided as an argument to the keyword/function raise.

Exceptions in Yona consist of three components. First component, is the type of the exception, represented as a symbol. The second component is a string description of the exception - an error message. Last component is the stacktrace which is appended by the runtime automatically.

Raising an exception can be accomplished by the raise expression:

raise :badarg "Error message"  # where :badarg is a symbol denoting type of exception

Catching an exception is done with try/catch. Catching an exception is essentially pattern matching on an exception triple that consists of all three exception components.

try
    expression
catch
    (:badarg, error_msg, stacktrace)  -> :error
    (:io_error, error_msg, stacktrace) -> :error
end

Loops: recursion and generators

Yona is a functional language with immutable data types and no variables. This means that imperative constructs for loops, such as while or for cannot be used. Instead, iteration is normally achieved via recursion. Yona is able to optimize tail-recursive function calls, so they would not stack overflow.

A typical Python solution using mutation might look like this:

def factorial(n):
    i = n
    while i > 1:
        i -= 1
        n *= i
    return n

However, this solution requires mutable variables, which are not present in Yona. So, an example of a recursive function to calculate factorial in Yona would be:

factorial 1 = 1
factorial n = n * factorial (n - 1)

Note that this function is actually not tail-recursive. This is because factorial (n - 1) is evaluated before n * (factorial (n - 1)), so the multiplication is the last expression here and thus there is a potential for stack overflow. That might often not be an issue, just something to consider when writing recursive functions.

It would actually not be very difficult to rewrite this function to be tail-recursive, such as:

factTR 0 a = a
factTR n a = factTR (n - 1) (n * a)

factorial n = factTR n 1

In this case there is a helper function that is tail-recursive, since the last expression in that function is the call to itself.

Generators

Another way to iterate over a collection (sequence, set or dictionary) are generator expressions. They allow transforming an existing collection into another one (of possibly different type, such as sequence to dictionary). Generators consist of three or four components:

Syntax for a generator generating a sequence from a set:

[x * 2 | x <- {1, 2, 3}]  # the source collection is a set of 1, 2, 3, so the result is [2, 4, 6]
[x * 2 | x <- {1, 2, 3} if x % 2 == 0]  # generator with a condition, so the result is [4]

Syntax for a generator generating a set from a sequence:

[x * 2 | x <- [1, 2, 3]]  # the source collection is a set of 1, 2, 3, so the result is {2, 4, 6}
[x * 2 | x <- [1, 2, 3] if x % 2 == 0]  # generator with a condition, so the result is {4}

Syntax for a generator generating a dictionary:

{key = val * 2 | key = val <- {:a = 1, :b = 2, :c = 3}}  # the source collection is a set of 1, 2, 3, so the result is {:a = 2, :b = 4, :c = 6}
{key = val * 2 | key = val <- {:a = 1, :b = 2, :c = 3} if val % 2 == 0}  # generator with a condition, so the result is {:b = 4}

Generators are an easy an convenient way to transform built-in collections. They are, however, themselves implemented using the reusable Transducers module. For example, a set generator without using the above mentioned syntax "sugar" could look like:

Transducers::filter \val -> val < 0 (\-> 0, \acc val -> acc + val, \acc -> acc * 2)
|> Set::reduce [1, 2, 3]

The description of the Transducer functions can be found in the module itself. Transducers may be combined in order to create more complex transformations. Also, custom collection may implement their version of a reduce function that accepts a transducer and reduces the collection as desired.

Strings

Strings in Yona are technically sequences of UTF-8 characters. Character in Yona can be written between apostrophes. Working with strings is then no different than working with any other sequence. String literals can be multi-line as well. There is no special syntax for multi-line strings and a single pair of quotes is used to denote all string literals.

String Interpolation

Yona supports string interpolation for convenience in formatting strings. The syntax is as follows:

"this string contains an interpolated {variable}"

In this string the {variable} part is replaced with whatever contents of the variable. Technically, variable is a name of a bound variable, or an expression in parentheses.

It is also possible to use string interpolation with alignment option, which can be used for formatting tabular outputs:

"{column1,10}|{column2, 10}"

The alignment number will make the value align to the right and filled with spaces to the left, if positive. If the number is negative, the opposite applies and the text is aligned to the left with spaces on the right. The number can be either a literal value or any expression that returns an integer.


Last update: August 25, 2021

Comments