Ursa language reference

See also:

Introduction

Ursa is a language that is designed to look familiar to users of mainstream languages circa 2020. It is an imperative functional language that will offer static type checking and a class system that will have a circa 2000 feel. It will come with a standard library that offers file system and network access, keyboard and mouse interaction, and basic sound and graphics support.

Once it reaches maturity, it is intended to remain stable for a long time; colloquially, there will be no version 2.0. However, it has not been thoroughly designed in advance, and until it reaches version 1.0, its design is subject to change.

The rest of this document will become the language reference.

Language basics

Ursa is a functional language: the basic syntactic unit is the expression. Every expression evaluates to some value.

Comments

Comments come in two flavours: single-line comments start with //, and continue until the end of the line. Comments delimited with /*…*/ may span multiple lines, and may be nested.

Identifiers

Identifiers, or symbols, consist of a letter or underscore followed by zero or more letters, digits and underscores. The definition of “letter” and “digit” have not yet been made precise.

abc
_
_12
sh33p

Built-in data types

The following data types are built in:

null

A distinguished value, used as the value of expressions that have no other natural value. It has the following methods:

  • equals
  • notEquals

These are also available as operators.

Boolean

This consists of the constants true and false. It has the following methods:

  • equals
  • notEquals
  • not

These are also available as operators.

Number

For now, floating point. Later, integers of various sizes will be distinguished from floating point.

For now, only decimal numbers are permitted, with an optional decimal point and fractional part (no exponent notation). Later, hexadecimal and binary will also be supported, as well as exponent notation.

3
42
-1

It has the following methods:

  • equals
  • notEquals
  • toString
  • pos
  • neg
  • bitwiseNot
  • lt
  • leq
  • gt
  • geq
  • add
  • sub
  • mul
  • div
  • mod
  • exp
  • bitwiseAnd
  • bitwiseOr
  • bitwiseXor
  • shiftLeft
  • shiftRight
  • shiftRightArith

These are also available as operators.

String

Unicode strings. Later, byte strings will also be supported. For now, the same as JavaScript’s, but must be delimited with double quotes. JavaScript-style escapes are permitted.

"abc"
"☺"
"\u2028"

Strings have the following methods available as operators:

  • equals
  • notEquals

The following methods are also available:

  • get(n) returns the nth character of the string (0-indexed).
  • iter() returns an iterator for the characters of the string.
  • split(sep) returns a list of sub-strings separated by the string sep.

List

Lists of arbitrary-typed values.

List literals are written between square brackets, with list items separated by commas:

[1, 2, 3]
[4, "hello", false]

Lists may be indexed (indices start at 0) and their length taken:

[1, 2, 3].get(1) // 2
[1, 2, 3].set(1

Lists have the following methods:

  • len() returns the number of elements in the list.
  • get(n) returns the nth element of the list, or null if n is not a valid index.
  • set(n sets the nth element of the list to value v. The list must already contain at least n values.
  • push(v) adds the value v to the end of the list.
  • pop() removes the last value from the list and returns it, or null if the list is empty.
  • slice([from[ returns a new list consisting of the items from from inclusive to to exclusive. If ${to} is omitted, items to the end of the original list are included in the new one. If from is also omitted, the entire list is copied.
  • iter() returns an iterator (see Iterators) that returns the list elements in order.
  • sorted() returns a new list with the elements sorted in ascending order.
  • join(sep) returns a string consisting of the elements of the list separated by the string sep.

Here is an example that uses iter:

for e in [1, 2, 3].iter() { print(e) }

Map

Maps with arbitrary-types keys and values.

Map literals are written between braces, with the key and value separated by a colon, and key–value pairs separated by commas:

{"a": 1, "b": 2, "c": 3}
{1: "a", 2: "hello", [1, 2, 3]: false}

Maps may be indexed. Note the final example: two lists with identical contents are not the same value!

{"a": 1, "b": 2, "c": 3}.get("a") // 1
{1: "a", "hello": 2, [1, 2, 3]: false}.set("hello"

The following methods are available:

  • get(k) returns the value corresponding to key k, or null if no such key exists.
  • set(k sets key k to value v, and returns the map.
  • delete(k) removes the entry for key ${k}, if any, and returns the map.
  • has(k) returns true if the map contains key ${k}, and false otherwise.
  • iter() returns an iterator (see Iterators) that successively returns lists of each key–value pair, in insertion order.
  • keys() returns an iterator over the keys, in insertion order.
  • values() returns an iterator over the values, in insertion order.

Object

Maps of symbolic properties to arbitrarily-typed values.

Object literals are written between braces, with a property name and value separated by an equals sign:

{a = 1, b = [1, 2, 3], c = false}

Objects have two methods:

  • equals
  • notEquals

Function

Functions are first-class. See the next section for more details.

Type annotations and definitions are built into the grammar, but are currently ignored, so this guide does not yet describe them.

Code

Ursa expressions combine the built-in data types above with operators, control-flow primitives, and functions.

Sequences

Expressions may be combined into a sequence by separating them with semi-colons. The value of a sequence is that of its last expression:

1; 2; 3 // value: 3
4; "hello" // value: "hello"

Semi-colons may usually be omitted at the end of a line.

Blocks

A block is a sequence written in curly braces. Blocks are used to clarify the syntax.

Declarations

Constants are declared with let and variables with var. Declarations must include an initial value:

let n = 3
let s = "hello"
let l = [1, 2, 3]
var total = 0

The value of a declaration expression is the value of its right-hand side. The scope of the identifier includes the initializer; this makes it easy to define recursive functions. On the other hand, the following is not allowed:

let x = 1     // x is 1
let x = x + 1

The second let redeclares x, and since x has not yet been given a value when the x on the right-hand side is evaluated, an error will result.

Simultaneous declarations may be made, separated by and, which allows mutually-recursive functions to be written easily.

let a = 3 and let b = 4

Operators

The following operators are available. They are given in decreasing order of precedence.

Unary operators

  • not logical not
  • ~ bitwise not
  • + the identity operation on numbers
  • - negation

Binary operators

  • ** exponentiation
  • * product
  • / quotient
  • % remainder
  • + addition
  • - subtraction
  • == equals (value equality for atomic values, reference equality for containers)
  • != not equals
  • < less than
  • <= less than or equal
  • > greater than
  • >= greater than or equal
  • & bitwise and
  • ^ bitwise exclusive-or
  • | bitwise or
  • << left shift
  • >> arithmetic right shift
  • >>> logical right shift

Assignments

A variable or object property may be assigned to with the assignment operator :=:

var x = 1
x := x + 1 // x is now 2

The value of an assignment expression is the value of its right-hand side.

Conditionals

The ifelse form is used to evaluate expressions conditionally. if is followed by the condition; if it evaluates to true, the block following the condition will be evaluated; otherwise the block following else will be:

if true {1} else {2} // value: 1
if false {3} else {4} // value: 4

Conditional expressions can be chained:

if false {1} else if false {2} else {3} // value: 3

The short-circuit boolean operators and and or are also provided. and evaluates its right-hand operand only if its left-hand operand is true. or evaluates its right-hand operand only if its left-hand operand is false:

true and 2  // value: 2
false and 2 // value: false
false or 2  // value: 2
true or 2   // value: true

For now, values are considered to be true or false as they would be in JavaScript; in future, only boolean values will be allowed.

Loops

A general loop is written with the loop operator followed by a block:

loop {} // loop forever

To exit a loop, use a break expression. The value of a loop, if it terminates, is the value supplied to break, if any; otherwise null.

loop { break 4 } // value: 4

The continue expression jumps back to the top of a loop.

There is a special form of loop for use with an iterator function, for:

for i of range(5) { print (i) }

See Iterators.

Functions

Functions are first-class in Ursa. A function is written using the fn keyword, followed by its formal parameters in parentheses, followed by its body as a block. The function parameters are constant (as if declared with let). The value of a function is the value of the last expression evaluated before it returns, or the value given with return, if any, or null. Functions are called by adding parentheses containing zero or more arguments to a function value:

let f = fn(x) { x + 1 }
f(1) // value: 2

Iterators

An iterator is just a function, but to be useful it should return a different value each time it is called, and then null when there are no more values. Lists and maps provide an iter method that iterates over the elements of the list or map. Iterators are often written using Generators.

Generators

Generators are functions where a single invocation can be called and return more than once. When a generator function is called, it returns a function that successively returns the values of the generator, until it returns null. After that, the generator will only return null.

The yield keyword is used to return a value from a generator. If return is used in a generator, then it will stop at that point.

Generators are introduced with the keyword gen (instead of fn), and yield may only be used in generators.

Here is an example of a generator that takes a list as input, and returns its values, doubled:

let g = gen(l) {
    for n of l.iter() { yield n * 2 }
}

When a generator is called, it takes a single argument which becomes the value of the yield expression. Here is an example of a generator that keeps a running total and returns the current total on each call:

let totalizer = gen() {
    var i = 0
    loop {
        i := i + (yield i)
    }
}
let t = totalizer()
print(t(0)) // prints: 0
print(t(3)) // prints: 3
print(t(2)) // prints: 5

Asynchronous execution

This area of Ursa is under development and will change in some details.

It is possible to make an expression run asynchronously by prefixing it with launch. This gives an “operation”.

let p = launch f()
g() // g may be called before f returns

Operations can be “awaited”:

let x = await p // x now has the value computed by f()

When the main program stops running, any currently-running operations are also stopped.

use

The use expression imports definitions from outside the current module. The expression use x.y.z is equivalent to let z = x.use("y.z").