×

Contents

  1. Introduction

  2. Expressions

    1. Collections

    2. Operators

    3. Precedence and Associativity

  3. Variables

  4. Functions

    1. Partial Functions

    2. Operator Functions

    3. User Functions

    4. Closures

  5. Control Flow

  6. Advanced Cordy

    1. Slicing

    2. Pattern Matching

    3. Decorators

    4. Assertions

    5. Structs and Modules

    6. Native Modules and FFI

Cordy Language

Cordy is a dynamically typed, interpreted, semi-functional / semi-procedural language, designed to be fast to write for scripting and solving puzzles. Its design is inspired primarily by Python, Rust, and Haskell. This document assumes some prior programming experience.

Introduction

[top]

>>> print 'hello world'
hello world

Cordy is a dynamically typed, interpreted language. A cordy program is compiled into a bytecode, and then the bytecode is interpreted. Cordy programs are made up of statements and expressions, similar to most procedural languages.

Cordy can be used in two basic modes:

  1. REPL (Read-Evaluate-Print Loop) Mode. When the cordy executable is invoked with no arguments, it opens a interactive session where individual expressions (or statements) may be entered, immediately executed, and the result printed back to the console. The symbol >>> indicates this line is an input in REPL mode.

  2. Compile Mode. When cordy my_first_program.cor is invoked, it will attempt to compile, then immediately execute the file my_first_program.cor.

A web-based REPL can be found here.

With that basic introduction, it's time to explore the basics of expressions...

Expressions

[top]

Expressions in Cordy are similar to expressions in many other procedural languages. They are a composition of values and operators. The basic types of values are:

'this is a string
with a newline in the middle'

Collections

[top]

Cordy also contains a number of collection types. These are mutable data structures which can contain other values:

Examples:

>>> [1, 2, 3] // a list
[1, 2, 3]
>>> (4, 5, 6) // a vector
(4, 5, 6)
>>> {1, 3, 5} // a set
{1, 3, 5}
>>> {1: 10, 2: 20, 3: 30} // a dictionary
{1: 10, 2: 20, 3: 30}

Cordy also allows the creation of user-defined structs and modules. But more on them later.

Operators

[top]

Operators in Cordy are similar to procedural languages, with all operators being infix operators by default. Cordy supports the following basic operators:

Precedence and Associativity

[top]

All operators are left associative (except = for assigning variables). Their precedence is noted as below, where higher entries are higher precedence:

Precedence Operators Description
1 [], (), if then else Array Access, Function Evaluation, Ternary if
2 -, !, not, -> Unary Negation, Bitwise Not, Logical Not, Struct Access
3 *, /, %, **, is, is not, in, not in Multiplication, Division, Modulo, Power, Is, Is Not, In, Not In
4 +, - Addition, Subtraction
5 <<, >> Left Shift, Right Shift
6 &, |, ^ Bitwise AND, Bitwise OR, Bitwise XOR
7 . Function Composition
8 <, <=, >, >=, ==, != Less Than, Less Than or Equal, Greater Than, Greater Than or Equal, Equals, Not Equals
9 and, or Logical And, Logical Or
10 =, +=, -=, *=, /=, &=, |=, ^=, <<=, >>=, %=, **=, .=, max=, min= Assignment, and Operator Assignment

Variables

[top]

Variables must be declared with let. They can optionally be followed by a initialization.

// A variable declaration, it is initialized to `nil`
let x

// A variable declaration and assignment
let y = 'hello'

Variable declarations can be chained:

let x, y, z

Even with assignments:

let x = 1, y = 2, z = 3

Variables with the same name may not be re-declared within the same scope:

let x = 1
let x = 2 // Compile Error!

However, variables in outer scopes may be shadowed by variables in inner scopes:

let x = 1
if x > 0 {
    let x = 2
    print x // prints 2
}
print x // prints 1

Variable names are mostly unrestricted - they may be any alphanumeric identifier that starts with a alphabetic character. However they may not share the name with a native function, of which the full list is found in the library documentation.

let map // Compile Error!

Functions

[top]

Functions in Cordy come in many different types. First, Cordy has a number of native functions which are provided by the Cordy standard library. These take the form of a few reserved keywords, like print, map and int.

Functions can be called in three different ways:

>>> print('hello') // calls 'print' with the argument 'hello'
hello
>>> 'world' . print // calls 'print' with the argument 'world'
world
>>> print 'goodbye' // calls 'print' with the argument 'goodbye'
goodbye

Note that the last type of evaluation is not always possible, and if it would be ambiguous with some other syntax, that is universally preferred. For example:

foo [1] // Evaluates to `foo[1]`, not `foo([1])`

Also note that when placed this way, arguments are evaluated one by one, meaning foo 1 2 3 will be interpreted as foo(1)(2)(3). This may be not an issue however, due to the presence of partial functions...

Partial Functions

[top]

Some native functions, and all user-defined functions, can be partially evaluated. This means that a function can be invoked with less than the required number of arguments, and it will return a new function which only needs to be invoked with the remaining arguments. One such example from the Cordy standard library is map:

let my_list = [1, -2, 3, -4, 5]

map(abs, my_list) // returns [1, 2, 3, 4, 5]

// We can partially evaluate `map(abs)` and then invoke that as a function
let f = map(abs)
f(my_list) // returns [1, 2, 3, 4, 5]

// Due to ( ) evaluation having higher precedence than . evaluation, we can also chain these two:
my_list . map(abs) // returns [1, 2, 3, 4, 5]

// This is also equivalent to the above
my_list . map abs // returns [1, 2, 3, 4, 5]

This mixing of the function composition operator (.) and regular function calls means that long sequential statements in Cordy can be written in a very functional style:

'123456789' . map(int) . filter(>3) . sum . print // Prints 39

Operator Functions

[top]

In addition to the Cordy standard library, which provides a number of native functions, every operator in Cordy can also be used as a function by surrounding it in ( parenthesis ). For example:

let add = (+)

add(2, 3) // returns 5

Note in some cases the additional parenthesis can be omitted, for instance if passing an operator to a function:

>>> print(+)
(+)

Operators can be partially evaluated by placing the partial argument either to the left, or right, of the operator. The placement affects which side of the operator is treated as partially evaluated:

let divide_3_left = (/6)
let divide_3_right = (6/)

divide_3_left(18) // same as 18/6 = 3
divide_3_right(3) // same as 6/3 = 2

Note that the parenthesis can also be omitted in the partial-evaluated operator when passing to a function:

[1, 2, 3, 4, 5] . filter(>3) // returns [4, 5]

User Functions

[top]

In addition to native and operator functions, Cordy allows the user to declare their own functions. These are variables, declared with the fn keyword.

Examples:

// A function named 'foo' which is followed by a statement body. It prints 'hello' and then returns 'world'
fn foo() {
    print('hello')
    return 'world'
}

// An anonymous function, which is stored in the variable 'f'. It takes one argument, and returns that argument plus three
let f = fn(x) -> x + 3

// A function named 'norm1' which takes two arguments, and returns the 1-norm of (x, y).
fn norm1(x, y) -> abs(x) + abs(y)

// A function named 'goodbye' which prints 'goodbye' and then returns nil.
fn goodbye() {
    print('goodbye')
}

As above, functions will return the last expression present in the function, or one specified via a return keyword. If no expression is given, they will return nil:

// these functions are semantically equivalent
fn three() { 
    3
}

fn three() { 
    return 3
}

fn three() -> 3

User functions can be partially evaluated:

// foo takes three arguments
fn foo(a, b, c) {
    a + ' and ' + b + ' or ' + c
}

let partial_foo = foo(1, 2)
partial_foo(3) // returns '1 and 2 or 3'

Functions can define optional and default arguments. All optional and default arguments must come after all other arguments in the function. Functions can be invoked with or without their optional or default arguments, which will take the default value nil (for optional arguments), or the default value (for default arguments).

Optional arguments are declared by appending a ? to the argument in the function declaration:

fn optional_argument(a, b?) -> print(a, b)

optional_argument('hello') // prints 'hello nil'
optional_argument('hello', 'world') // prints 'hello world'

Default arguments are declared by appending an = followed by an expression to the argument in the function declaration:

fn default_argument(a, b = 'world') -> print(a, b)

default_argument('hello') // prints 'hello world'
default_argument('hello', 'earth') // prints 'hello earth'

Note that default argument values are constructed each time, and are not mutable across function calls, unlike in Python:

fn foo(a = []) {
    a.push('yes')
    print(a)
}

foo() // prints ['yes']
foo() // prints ['yes']

Functions can be called with unrolled arguments. This unrolls an iterable into a sequence of function arguments, by prepending a ... to the argument in question. This is like the unary * operator in Python:

fn foo(a, b, c) -> print(a, b, c)

// All of the below call `foo(1, 2, 3)`, and print '1 2 3'
foo(...[1, 2, 3]) // unrolls each list element as an argument
foo(...[1, 2], 3) // they can be used with normal arguments, in any order
foo(...[], 1, ...[2], 3, ...[]) // An empty iterable is treated as adding no new arguments

In addition to this, they support variadic arguments (via a * like in Python). These must be the last argument in the function, and they collect all arguments into a vector when called:

// Note that `b` will be a vector of all arguments excluding the first. It may be empty.
fn foo(a, *b) -> print(b)

foo('hello') // prints ()
foo('hello', 'world') // prints ('world')
foo('hello', 'world', 'and', 'others') // prints ('world', 'and', 'others')

Closures

[top]

Functions can reference local and global variables outside themselves, mutate them, and assign to them. Closures are able to reference and mutate captured variables, even after they have fallen out of scope of the original declaration.

fn make_box() {
    let x = 'hello'
    fn foo() -> x = 'goodbye'
    fn bar() -> x.print
    (foo, bar)
}
let foo, bar = make_box()

bar() // prints 'hello'
foo()
bar() // prints 'goodbye'

Variables declared in loops are captured each iteration of the loop. So the following code:

let numbers = []
for i in range(5) {
    numbers.push(fn() -> i)
}
numbers . map(fn(f) -> f()) . print

Will print the sequence [1, 2, 3, 4, 5], as intuitively expected.

Control Flow

[top]

Cordy has a number of procedural style control structures, some familiar from C, Python, or Rust.

loop is a simple infinite loop, which must be terminated via the use of break (which exits the loop), or return:

loop {
    // statements
}

while is a conditional loop which evaluates so long as the expression returns a truthy value (N.B. false, 0, nil, '', and empty collections are all falsey values - everything else is truthy.):

while condition {
    // statements
}

Like in Python, it can have an optional else, which will only be entered if a break statement was not encountered.

while condition {
    // statements
} else {
    // only if no `break`
}

do-while is a variant of the above which runs statements first, then evaluates the loop

do {
    // statements
} while condition

Like a typical while loop, it can also have an optional else block, which will only be entered if a break statement was not encountered.

do {
    // statements
} while condition else {
    // only if no `break`    
}

Note that the while condition can be omitted entirely, in which case the do { } statement functions as a single scoped block, that also supports break (jump to the end of the block) and continue (jump to the top of the block) semantics.

for-in is a loop which iterates through a collection or string, yielding elements from the collection each iteration.

// declares `x` local to the loop
for x in collection {
    // statements
}

Note: break and continue can be used in all loop-like structures, which will exit the loop (break), or jump to the top of the next iteration of the loop (continue)

Finally, if, else, elif perform control flow not in expressions:

if condition1 {
    // statements
} elif condition2 {
    // statements
} else {
    // statements
}

Note that if, else with { curly brackets } are not expressions, and thus don't produce a value, however the if, then, else block is, and so does produce a value.

Advanced Cordy

[top]

The below series of features are arbitrarily deemed advanced.

Slicing

[top]

List, vector, and string indexing works identically to Python:

>>> let my_list = [1, 2, 3, 4, 5]
>>> my_list[0]
1
>>> my_list[len(my_list) - 1]
5
>>> my_list[-1]
5
>>> my_list[-3]
3

Lists, vectors, and strings can also be sliced like in Python. A slice takes the form [ <start> : <stop> : <step> ] or [ <start> : <stop> ].

Examples

>>> [1, 2, 3, 4] [:]
[1, 2, 3, 4]
>>> [1, 2, 3, 4] [1:]
[2, 3, 4]
>>> [1, 2, 3, 4] [:2]
[1, 2]
>>> [1, 2, 3, 4] [:-2]
[1, 2]
>>> [1, 2, 3, 4] [1::2]
[2, 4]
>>> [1, 2, 3, 4] [1:-1:3]
[2]
>>> [1, 2, 3, 4] [3:1:-1]
[4, 3]

Pattern Matching

[top]

Values can be destructured into multiple variables, like in Python or Rust:

x, y, z = [1, 2, 3] // Assigns x = 1, y = 2, and z = 3

A pattern lvalue is supported in the declaration of let statements and for statements. This consists of a comma-seperated sequence of pattern elements. Pattern elements may be named variables, _ (which discards a value), or nested patterns with ( parenthesis ). One element in a pattern may be prefixed with *, which indicates it collects all elements in the iterable over the length of the pattern. When assigning to a pattern, the iterable must be the same length (or longer, if any * terms are present) as the pattern.

let x, y, z = [1, 2, 3] // Assigns x = 1, y = 2, and z = 3
let first, *rest = [1, 2, 3, 4] // Assigns first = 1, rest = [2, 3, 4]
let a, (b, c), d = [[1, 2], [3, 4], [5, 6]] // Assigns a = [1, 2], b = 3, c = 4, and d = [5, 6]
let _, _, three, _ = [1, 2, 3, 4] // Assigns three = 3 and discards the other values

Patterns can also be used in the declaration of function parameters. When used this way, they must be surrounded by an additional set of ( parenthesis ). These are then treated as if the pattern destructuring happened in a let statement immediately within the function:

fn foo(a, (b, (c, _), _)) { ... }

// The above function takes two arguments, and is identical to:
fn foo(a, x) {
    let b, (c, _), _ = x
    ...
}

Patterns can also be used in assignment statements within expressions. When used this way:

x, y = y, x // Swaps the values of x and y, by constructing the vector (x, y), and assigning each part to the opposite variable
a[i], a[j] = a[j], a[i] // Swaps the values of `a` at index i and j.

Note that patterns are evaluated right-to-left. This doesn't matter unless the pattern and right hand side value reference the same mutable object(s):

let A = [1, 2, 3]

// First,  A[0] = A[2], so A = [3, 2, 3]
// Second, A[2] = A[1], so A = [3, 2, 2]
// Third,  A[1] = A[0], so A = [3, 3, 2]
A[1], A[2], A[0] = A

print A // prints [3, 3, 2]

Decorators

[top]

Named functions can optionally be decorated, which is a way to modify the function in-place, without having to reassign to it. A decorator consists of a @ followed by an expression, before the function is declared:

@memoize
fn fib(n) -> if n <= 1 then 1 else fib(n - 1) + fib(n - 2)

This can be understood as the following:

fn fib(n) -> if n <= 1 then 1 else fib(n - 1) + fib(n - 2)
fib = memoize(fib)

Decorators can be chained - the innermost decorators will apply first, then the outermost ones. They can also be attached to anonymous functions. The expression of a decorator must resolve to a function, which takes in the original function as an argument, and outputs a new value - most typically a function, but it doesn't need to. For example:

fn run(f) -> f()

@run
fn do_stuff() { print('hello world') } // prints 'hello world' immediately, and assigns `do_stuff` to `nil`

Assertions

[top]

The assert keyword can be used to raise an error, or assert a condition is true. Note that runtime errors in cordy are unrecoverable, meaning if this assertion fails, the program will effectively call exit. An assert statement consists of assert <expression>, optionally followed by : <expression>, where the second expression will be used in the error message.

assert false // Errors with 'Assertion Failed: nil'

assert false : 'Oh no!' // Errors with 'Assertion Failed: Oh no!

Assertions will point to the expression in question being asserted:

Assertion Failed: message goes here
  at: line 1 (<test>)
  
1 | assert false : 'message goes here'
2 |        ^^^^^

Structs and Modules

[top]

Cordy can allow the user to define their own type via structs. These are a type with a fixed set of values that can be accessed with the field access operator ->. They can be used to program in an object-oriented style within Cordy.

A struct is declared with the struct keyword, a struct name, and a list of field names. These names - unlike variable declarations - may shadow native function names or other variable names:

>>> struct Point(x, y)

New instances of a struct can be created by invoking the struct name as a function:

>>> let p = Point(2, 3)
>>> p
Point(x=2, y=3)

Fields can be individually accessed or mutated with the -> operator:

>>> p->x
2
>>> p->x = 5
5
>>> p
Point(x=2, y=3)

When declaring a struct, it can be optionally followed by an implementation block. This block of code can define functions which are local to the struct - called struct methods.

struct Point(x, y) {
    fn zero() -> Point(0, 0)
}

These functions can be invoked as fields on the struct name itself:

>>> Point->zero()
Point(x=0, y=0)

Note that struct methods can optionally take a self keyword as their first parameter. This creates instance methods, which can be invoked on instances of the struct. These have a few important properties:

Examples:

struct Point(x, y) {
    fn norm1(self) {
        abs(self->x) + abs(y) // Fields can be accessed with or without 'self->'
    }

    fn is_norm1_smaller_than(self, d) {
        norm1() < d // Methods can be called, where 'self->' is implicit.
    }
}

let p = Point(3, 5)

p->norm1() // Returns 8
Point->norm1(p) // Returns 8

p->is_norm1_smaller_than(10) // Returns 'true'

Modules are like structs, but with a few key differences:

module Powers {
    fn square(x) -> x ** 2
    fn cube(x) -> x ** 3
}

Powers->square(3) // Returns 9
Powers->cube(4) // Returns 64

Native Modules and FFI

[top]

Cordy has a basic FFI (Foreign Function Interface) that can be used to interface with external C-compatible libraries. In order to do this, native modules are required. A native module is like a normal module with a few differences:

// In a file my_ffi_test.cor
native module my_ffi_module {
    fn hello_world()
}

my_ffi_module->hello_world()

When compiling cordy, for each native module present in the Cordy code, it will need to provide a --link (or -l) argument in order to link the native module, with a compile shared object library:

$ cordy --link my_ffi_module=my_ffi_lib.so 

This native module must then export the symbol hello_world as a function - compatible with C. In order to do this, and facilitate the passing of values to and from native code, there is a header which can be used to create a C-compatible function. This provides a number of types and macros to make writing Cordy FFI functions easier.

Example:

// In a file my_ffi_lib.c
#include <stdio.h>
#include "cordy.h"

CORDY_EXTERN(hello_world) {
    printf("Hello World!\n");
    return NIL()
}

This can then be compiled and invoked with the --link parameter mentioned above:

$ gcc -shared -o my_ffi_lib.so my_ffi_lib.c
$ cordy --link my_ffi_module=my_ffi_lib.so my_ffi_test.cor
Hello World!