Language Documentation

The purpose of this documenation is to briefly describe every aspect of the Juniper programming language, and provide usage examples. If you're already proficient with another functional programming language this documentation should be sufficient to get you up to speed on Juniper. Juniper is a ML family language, so knowing another ML family language (ex: Haskell, OCaml, SML, F#) will be of great benefit.

All language features can be seen in the language grammar file.

Modules

Juniper modules are used to organize code into logical groups and prevent naming collisions. Each module is defined in its own .jun file. A Juniper module should always start with the module keyword, followed by the module name. The name of the module should be the same as the name of the .jun file. The open() declaration is then used to import all exported declarations from another module into a current module. Otherwise, the module name followed by a colon and the declaration name ModuleName:declarationName can be used to refer to a declaration in another module.

Comments

Single line comments in Juniper begin with two slashes //. A multiline comment begins with (* and ends with *).

Functions

Juniper functions are declared using the fun keyword, followed by the name of the function, an optional template, the parameters list, and a required return type. Let’s take a look at a simple function which will double a number.

fun doubleMe(x : int16) : int16 = x + x

As you can see in the example above, the parameters of the functions are placed in parentheses and the colons indicate type constraints.

The general structure of a function declaration is as follows:

fun funname<template>(a1 : t1, a2: t2, ..., an : tn) : rettype = expr

If Statements

If statements are used to control whether or not the program enters a section of code. In Juniper, if statements return values, much like the ternary operators in imperative languages like Javascript and C++. For this reason, all if statements must have an else branch.

Let’s take a look at a simple if statement which is a slight modification of the previous function.

fun doubleSmallNumber(x : int16) : int16 = if x > 100 then x else 2*x end

The general structure of an if statement is as follows:

if condition then truebranch else falsebranch end

if cond1 then expr1 elif cond2 then expr2 elif ... else exprN end

Binary and Unary Operations

Juniper has support for many common operations:

Keyword Unary/Binary Meaning

and

Binary

Logical and

or

Binary

Logical or

not

Unary

Logical not

+

Binary

Addition

-

Binary

Subtraction

*

Binary

Multiplication

/

Binary

Division

mod

Binary

Modulo

>=

Binary

Greater than or equal

<=

Binary

Less than or equal

>

Binary

Greater than

<

Binary

Less than

==

Binary

Equal

!=

Binary

Not equal

&&&

Binary

Bitwise and

|||

Binary

Bitwise or

<<<

Binary

Bitshift left

>>>

Binary

Bitshift right

~~~

Unary

Bitwise not

-

Unary

Negate

The general structure of a binary operation is:

expr binary-op expr

The general structure of a unary operation is:

unary-op expr

Parentheses can be used to group operators in different ways.

Datatypes

Primitive Types

Juniper has a number of different primitive types built into the language: int8, uint8, int16, uint16, int32, uint32, int64, uint64, float, double, bool, unit and pointer

The intN types represent a signed integer of size N bits. The uintN types represent an unsigned integer of size N bits. Integer types can be constructed simply by typing the number (ex: 6, 480)

float and double are two floating point types with different precisions. The float type takes up 32 bits and the double type takes up 64 bits. The advantage of the double type is that it is more precise than the float type. Floating point types can be constructed by typing the number with a decimal point (ex: 42.0, 3.14)

The bool type represents a boolean value which can either have the values true or false.

The unit type is a type with only one value, and it can be thought of as a zero element tuple. The value for the unit type can be constructed using empty parentheses ().

The pointer type represents a C++ smart pointer. The pointer type is really only useful when interacting with other C++ code. If you’re writing a wrapper around a C++ library, you may come across situations in which it is necessary to use this type. This type is rarely encountered in typical Juniper programs.

Tuples

Tuple types are an ordered collection of values of different types. Tuples are constructed using parentheses with expressions separated by commas.

(expr1, expr2, ..., exprN)

The type of a tuple can be written as a product of types, separated by asterisks.

t1 * t2 * ... * tN

For example, the two element tuple

(true, ())

Has type bool * unit

Record Types

Record types represent aggregates of named values. Record types are defined by using the type keyword, followed by the record name, an optional template, and a list of field names separated by semicolons and enclosed in curly braces. Each field name has a type associated with it, which is indicated by the colon followed by the type.

The general structure of a record type declaration is:

type recordName<template> { fieldName1 : t1; fieldName2 : t2; ... ; fieldNameN : tN }

A record can be constructed using a record expression. A record is constructed by typing the record name, followed by an optional template and a pair of curly braces. Within the curly braces, each field name must be present, along with an expression initializing each field.

The general structure of a record expression is as follows:

recordName<template> { fieldName1=expr1; fieldName2=expr2; ... ; fieldNameN=exprN }

Note that the fields do not have to appear in any particular order.

To access a particular field of a record, use a dot followed by the field name.

expr.fieldName

The record dot notation is still supported but should not be used in most programs. Use pattern matching to access a record field. Record dot notation is still used in the standard library.

The type of a record is simply the name of the record being created.

Algebraic Data Types

Algebraic data types in Juniper are variant types representing a tagged union. Each named variant has its own value constructor, which is just a function whose return type is the name of the algebraic data type.

Algebraic data types can be declared with the type keyword, followed by the type name and an optional template. This is followed by an equal sign and a pipe separated list of value constructors. Each value constructor has a name, followed optionally by the of keyword and the contained type.

The general structure of an algebraic data type declaration is:

type typeName<template> = valCon1 [of t1] | valCon2 [of t2] | ... | valConN [of tN]

The type of each value constructor is either

() -> typeName

In the case where the of keyword is omitted or

(tN) -> typeName

in the case where the value constructor contains another type.

Here is an example of an algebraic data type representing shapes. The circle value constructor takes in a tuple representing the position of the center of the circle and its radius. The rectangle value constructor takes in a tuple representing the position of the top left corner of the rectangle and its width and height.

type shape = circle of (float * float * float) | rectangle of (float * float * float * float)

circle is a function with the following type:

(float * float * float) -> shape

rectangle is a function with the following type:

(float * float * float * float) -> shape

Sequences

A sequence is a list of expressions enclosed in parentheses and separated by semicolons. The return value of a sequence is the value returned by the last expression in the sequence. The return type of the sequence is the value returned by the last expression. For example, the following sequence is of type int32 and returns a value of 2.

(true; 2)

The general structure of a sequence is:

(expr1; expr2; ...; exprN)

Sequences are often used when binding a variable using a let expression, which are discussed in the next section.

Let Expressions

A let expression is used to bind a variable for later use. A let expression begins with the keyword let, followed by the variable name, and optionally a colon and the type of the variable. This is followed by an equals sign and an expression whose value will be assigned to the variable. Consider the following example:

(let x = 5; let y = 3; x + y)

In this code, the variable x is bound, making it available in later expressions in the sequence. Then the variable y is bound, making it available as well. The return value of this expression is 8, and the return type is int32.

Type constraints can also be added to let expressions. Although not necessary in let expressions, a constraint may aid in catching type errors. A type constraint can be added by inserting a colon followed by the type after the variable name. Here is the same code above, but with type constraints added.

(let x : int32 = 5; let y : int32 = 3; x + y)

The general form of a simple let expression in a sequence is:

(...; let varName = expr; ...)

(...; let varName : typeConstraint = expr; ...)

In reality, let expressions are much more powerful than the simple examples presented above. See the section on pattern matching for more details.

Mutation and References

Variables can be marked as mutable by inserting the mutable keyword in front of the variable name. By default variables are immutable, which means that they cannot be changed after they are bound.

The mutable keyword is usually used in conjunction with a set expression. A set expression begins with the set keyword, followed by a left assign expression, an equal sign and then the new value. Here is a simple example of using mutation to sum an array of integers:

fun sum<;n>(arr : int32[n]) : int32 = ( let mutable sum = 0; for i : uint32 in 0 to n - 1 do set sum = sum + arr[i] end; sum )

Set expressions can mutate certain fields of a record or a certain value in an array using the record dot notation or array square bracket notation.

Here is an example of mutating the field of a point record.

(let mutable p = point { x=2; y=3 }; set p.x = 5; p)

Here is another example that uses the array square bracket notation. This function will add make a copy of the input array, add one to every value in the array, and then return that array.

fun addOne<;n>(arr : int32[n]) : int32[n] = ( let mutable ret = arr; for i : uint32 in 0 to n - 1 do set ret[i] = ret[i] + 1 end; ret )

A reference is a pointer to a location in memory (on the heap). A value of type bool ref is a pointer to a location in memory, where the location in memory contains a boolean. It's similar to bool* in C/C++. A ref is like a box that can store a single value. The value inside of a reference can be accessed by placing a exclamation point befor the reference expression. The contents of a reference can be updated by using the set ref keywords in a similar manner to the ordinary mutable variables above.

Here is an example of a function that will double the contents of a float ref. Notice that the return type is unit in this function.

fun double(x : float ref) : unit = ( set ref x = 2.0 * !x; () )

References can be created using the ref keyword. In this example we pass a reference to the funcion we defined above. The return value of this expression is 8.0.

(let x : float ref = ref 4.0 double(x); !x)

In general a reference can be created using:

ref expr

The value of a reference can be retrieved using:

!expr

The contents of the reference can be changed using:

set ref varName = expr

Internally, the memory for references are managed by a reference counting system (also called a smart pointer in the C++ world). This means that if a cycle is created among references, the memory will fail to be freed.

Lambdas

Functions are first class entities in Juniper. Lambdas (also called anonymous functions) are used extensively in Juniper. When combined with higher order functions, they allow very powerful methods of abstraction to be used. Functions in Juniper also support closures. However, variables marked as mutable will not be mutable inside of the lambda closure.

Lambdas are declared using the fn keyword, followed by a pair of parentheses containing the arguments of the lambda, then the return type, a right arrow -> then the lambda body, then the end keyword.

Here is an example of using a lambda in conjunction with the List:foldl higher order function in order to sum a list of int32 numbers.

fun sum(lst) = List:foldl(fn (x, total) -> x + total end, 0, lst)

Here is another example of the addOne example used in the previous section.

fun addOne(lst) = List:map(fn (x) -> x + 1 end, lst)

Here is an example of using a closure in Juniper. The return value of this expressions is 42. In this example the type of myFun is () -> int32.

(let myFun = (let theAnswer = 42; fn () -> theAnswer end); myFun())

Loops

Juniper includes a number of different imperative loops. The loops supported are for loops, while loops and do while loops.

The general structure of a while loop and a do while loop are respectively:

while conditionExpr do bodyExpr end

do bodyExpr while conditionExpr end

The for loops can interate either up or down. In both cases the interval iterated over by the loop is inclusive.

The general structure of a for loop is:

for indexVar : type in lowExpr to highExpr do bodyExpr end

for indexVar : type in highExpr downto lowExpr do bodyExpr end

These for loops are roughly equivalent to these C++ loops:

for (type indexVar = lowExpr; indexVar <= highExpr; i++) { bodyExpr }

for (type indexVar = highExpr; indexVar >= lowExpr; i--) { bodyExpr }

The return type of all loops is unit.

Pattern Matching

Pattern matching is a powerful feature in many functional programming languages. Pattern matching ensures that some value conforms to some form while also providing a way to deconstruct it. Pattern matching in Juniper happens in the case expression and in the let expression.

A case statement in Juniper begins with the keyword case, followed by the value to pattern match on and then the keyword of. This is followed by a list of pipe separated case clauses and the end keyword. Each case clause consists of a pattern followed by a fat arrow => then an expression to execute if the pattern matches. The underscore character _ can be used in a pattern to indicate a wildcard pattern match. Pattern matching can be used on numbers, value constructors, tuples and records.

In general the syntax is:

case valueExpr of | pattern1 => expr1 | pattern2 => expr2 ... | patternN => exprN end

let pattern = valueExpr

Here is an example of deconstructing a tuple using pattern matching and the let expression. This function returns the third element of any tuple.

fun third<'a,'b,'c>(tup : ('a * 'b * 'c)) : 'c = ( let (_, _, x) = tup; x )

Here is an equivalent function that uses a case expression:

fun third<'a,'b,'c>(tup : ('a * 'b * 'c)) : 'c = case tup of | (_, _, x) => x end

Beginning in Juniper 2.1.0, type inference can be used to simplify the example above.

fun third(tup) = ( let (_, _, x) = tup; x )

Here is an example of using pattern matching on value constructors:

type color = red | green | blue fun nextColor(c : color) : color = case c of | red() => green() | green() => blue() | blue() => red() end

In the following example, a distance function is declared which gives the distance between two 2D points.

type point = { x : float; y : float } fun distance(p1 : point, p2 : point) : float = ( let point { x=x1; y=y1 } = p1; let point { x=x2; y=y2 } = p2; let dx = x1 - x2; let dy = y1 - y2; Math:sqrt_((dx * dx) + (dy * dy)) )

Templates and Capacities

In Juniper, templates enable parametric polymorphism. Parametric polymorphism enables functions and types to be written which are generic over other types. Type variables are used to express this genericity. A template is declared using angle brackets. Inside is a comma separated list of type variables, where each type variable has a single quote ' (tick) in front of it. These templates can be declared just after the name of a function, algebraic data type or record.

As an example of a templated algebraic data type, here is the declaration for the maybe type, as declared in the Prelude module:

type maybe<'a> = just of 'a | nothing

Here is an example of a function which operates on the maybe type, as declared in the Maybe module. The map function takes in another function, and uses it to map the value contained in the maybe if it is not nothing().

fun map<'a,'b>(f : ('a) -> 'b, maybeVal : maybe<'a>) : maybe<'b> = case maybeVal of | just<'a>(val) => just<'b>(f(val)) | _ => nothing<'b>() end

Here is the compose function, as defined in the Prelude module:

fun compose<'a,'b,'c>(f : ('b) -> 'c, g : ('a) -> 'b) : ('a) -> 'c = fn (x : 'a) : 'c -> f(g(x)) end

Beginning in Juniper 2.1.0, the above types can now be inferred. The above compose function could be written as:

fun compose(f, g) = fn (x) -> f(g(x)) end

Capacity expressions put compile time constraints on the sizes of data structures. In particular, the built in array type uses a capacity variable to determine the amount of space occupied in memory at compile time. Consider the following example:

let myBoolArray : bool[5] = [true, false, false, true, true]

In the above example, the number 5 is hard coded. Obviously hard coding the number will not be useful in general. To get around the need for hard coding, capacity variables are used. Capacity variables can be declared after type variables, and consists of a comma separated list of names. The capacity variables are separated from the type variables by a semicolon. For example, the list record in the Prelude is defined as:

type list<'a; n> = { data : 'a[n]; length : uint32 }

And here is the function last, as defined in the List module. In this case the capacity variable is n.

fun last<'t;n>(lst : list<'t;n>) : 't = lst.data[lst.length - 1]

Capacity variables can even act sort of like dependent types. This flattenSafe function takes in a list of lists and returns a list of capacity m*n.

fun flattenSafe<'t;m,n>(listOfLists : list;n>) : list<'t;m*n> = ( let mutable ret = array 't[m*n] end; let mutable index : uint32 = 0; for i : uint32 in 0 to listOfLists.length - 1 do for j : uint32 in 0 to listOfLists.data[i].length - 1 do (set ret[index] = listOfLists.data[i].data[j]; set index = index + 1) end end; list<'t;m*n>{data=ret; length=index} )

The arithmetic operations that can be used on capacity variables includes multiplication, addition, subtraction and division. Parentheses can be used to group arithmetic operators.

Arrays and Lists

An array is a series of elements of the same type that can be individually referenced by using an index as a unique identifier. In Juniper, an array also has a capacity associated with its type, which determines how much memory the array consumes.

Arrays can be created using an array literal syntax. The array literal syntax consists of a list of comma separated expressions surrounded by square brackets. The type of the expressions in the array literal must be the same. The array literal syntax is as follows:

[expr1, expr2, ..., exprn]

Which has type t[n], where t is the type of expr1, expr2, ..., exprn.

Arrays can also be created using the replication syntax, which creates an array filled with a given value. The array replication syntax consists of the array keyword, followed by the type of the array, the of keyword, the expression which determines the end value and finally the end keyword.

array t[n] of fillExpr end

There is also an unsafe array creation syntax, which should only be used in very specific circumstances. This is primarily used by the Juniper standard library and is not usually necessary when writing standard Juniper code. The following is unsafe since none of the array elements are initialized:

array t[n] end

Values of an array can be accessed using the square bracket array access notation:

arrayExpr[indexExpr]

There is currently no support for a list literal syntax. However, additional support for lists are planned for future releases. Currently a list is just a record type as defined in the Prelude module:

type list<'a; n> = { data : 'a[n]; length : uint32 }

Interacting with C++ Libraries

Using existing C++ libraries in Juniper is perhaps the most tricky and error prone part of the language. Typically if you wish to use a C++ library in your Juniper project, you should begin by writing a wrapper module around the library. The quickest way to get started is to take a look at already existing wrappers. Typically wrappers all follow the same pattern, so modifying an existing wrapper should be fairly straightforward. Here are some example wrappers bundled with the Juniper distribution:

Juniper allows C++ code to be written inline wherever an expression can be written. Inline C++ code is wrapped inside of an immediately invoked function, which means it is impossible to introduce variables into the current function scope. The return value of the immediately invoked function is unit, which means that the return value of any inline C++ code is unit. Inline C++ code is written between two hashtag # symbols.

#Insert your C++ code here#

In Juniper wrappers the pointer type is used to point to a memory location. This pointer type is actually a C++ smart pointer object. The smart pointer will keep track of the number of references to your C++ object and automatically delete (free the memory) when there are no more references to it. Internally, the smart pointer keeps track of your C++ object by using a void * pointer. This means that you must ensure that you are making the proper typecasts when interacting with the smart pointer in C++ code.

To get started with smart pointers, declare a new variable of type pointer by using the null keyword.

let p : pointer = null

At this point p is a variable of type pointer, which is secretly the juniper::shared_ptr<void> C++ type. The null keyword indicates that the smart pointer is currently pointing to the C++ value NULL. You can change what the smart pointer is pointing to by using the set method of the shared_ptr C++ class. The set method simply takes a single parameter of type void *.

#p.set((void *) new MyClass(...));#

To access the contents of the smart pointer, use the get method of the juniper::shared_ptr class. The get method takes in no parameters and returns the pointer as the C++ type void *. It is up to you to cast the pointer to the proper type so it can be used to interact with your object.

#((MyClass *) p.get())-> ... ;#

Juniper performs no name mangling of variable names, type names, or function names. This means that inline C++ can safely use these entities without restriction. For example, we can retrieve an integer stored in MyClass by using the following code:

(let mutable x : int32 = 0; #x = ((MyClass *) p.get())->getX();#; x)

Headers can be included into Juniper compiled source code by using the include() declaration. An include declaration takes in a list of double quoted C++ header files to #include in the Juniper source code. For example, in the Neopixel wrapper library, this include declaration is used:

include("<Adafruit_NeoPixel.h>")

Type Inference

In Juniper 2.0.0 a new type inference engine was added to the language. Most type constraints can now be ommited since Juniper can automatically infer the correct type. For example, types of parameters in functions and lambdas may be ommited. Templated functions do not need to have their type expressions explicitly given since they can now be inferred.

Strings and Character Lists

In Juniper 2.1.0 strings and character lists were added to the language. Strings can be created by placing extended ASCII characters inside double quotes. Character lists can be created by placing extended ASCII characters inside single quotes. Strings are pointers to a location in the program memory space, whereas character lists are stored on the stack. If you need to print debug messages, use strings. If your program needs to manipulate text, use character lists. Character lists are just lists of type uint8.

Pipe Operator

In Juniper 2.1.0 the pipe operator was added. It functions similarly to the pipe operator in F# and Elixir. The syntax for the pipe operator is:

expr |> myFunc(arg1, arg2, ..., argN)

which is equivalent to:

myFunc(arg1, arg2, ..., argN, expr)

The pipe operator takes an expression on its left side and a function invokation on its right side. The expression on the left is placed as the last argument of the function call on the right. This is useful for chaining a sequence of transformations of a signal or a list. The pipe operator is left associative.