ludus/language.md
2024-06-15 21:50:46 -04:00

27 KiB

Ludus language reference

This is not intended for beginners, but to be a language overview for experienced users. That said, it may help beginners orient themselves in the language.

Comments

Ludus's comment character is &. Anything after an ampersand on a line is ignored. There are no multiline comments.

Atomic values

Ludus has four types of atomic values.

nil

nil is Ludus's representation of nothing. In the grand Lisp tradition, Ludus can, and frequently does, use nil-punning. Its type is :nil.

Booleans

true and false. That said, in all conditional constructs, nil and false are "falsy," and everything else is "truthy." Their type is :boolean.

Numbers

Ludus has numbers. At current, they rely on underlying number types. When Ludus runs in the browser, numbers are Javascript 64-bit floats. When Ludus runs at the command line in JVM-based Clojure, Ludus numbers could be ints, floats, or ratios, depending. Numbers are more complicated than you think.

Number literals in Ludus are either integers or decimal floating point numbers.

Numbers' type is :number.

Keywords

Ludus keywords begin with a colon and a letter, e.g. :keyword. Types are represented as keywords. Some functions take an optional units argument as a keyword, e.g. :radians. Keywords are also used as keys for associative collections. Keywords' type is :keyword.

Keywords must begin with an ASCII upper- or lower-case letter, and can then include any letter character, as well as _, /, !, ?, and *.

Strings

Ludus strings are UTF-8 strings, and only use double quotes. Strings may be multiline. For example, this is a string: "foo". So is this:

"foo


bar baz"

Strings use backslashes for escapes, including \n for newline, \t for tab, \" for a double quote, and \{ for an open curly brace (see below on interpolation).

Strings' type is :string.

String interpolation

Strings may also insert a string representation of any Ludus value that is bound to a name, by inserting that name in curly braces, e.g.

let foo = :foo
let bar = 42
let baz = [1, 2, 3]
"{foo} {bar} {baz}" &=> ":foo 42 1, 2, 3"

Interpolations may not be arbitrary expressions: only bound names may be used in interpolations.

Collections

Ludus has a few different types of collections, in increasing order of complexity: tuples, lists, sets, dicts, and namespaces.

Separators

In all collection literals, members are written with a separator between them. On the same line, use a comma; or a newline will also separate elements. You may use as many separators as you wish at any point inside a collection or pattern. (,,,,,,,3,,4,,,,,,) and (3, 4) are the same value.

Tuples

Tuples are fully-immutable, ordered collections of any kinds of values, delimited by parentheses, e.g. (1, :a, "foo"). At current, they have no length limit (although they eventually will). Unlike in some languages, tuples can be empty or contain a single element: () and (:foo) are both just fine. Tuples largely cannot be manipulated functionally; they must be written as literals and unpacked using pattern matching. They can, however, be converted to lists, either through pattern matching or the list function. Their type is :tuple.

Lists

Lists are persistent and immutable ordered collections of any kinds of values, delimited by square braces, e.g. [1, :a, "foo"]. Their type is :list.

Lists may be combined using splats, written with ellipses, e.g., [...foo, ...bar].

Sets

Sets are persistent and immutable unordered collections of any kinds of values, which can only contain one instance of any given value. They are written similarly to ordered collections: ${1, :a, "foo"}. Their type is :set.

Dictionaries, or dicts

Dicts are persistent and immutable associative collections of any kinds of values. Dicts use keywords as keys (and cannot use any other kind of Ludus value as a key, not even strings), but can store any values. Dict literals are written as keyword-value pairs: #{:a 1, :b false}. Single words may be used as a shorthand for a key-value pair. Accessing a key that holds no value returns nil. Their type is :dict.

Packages

Packages are immutable collections of bindings. They may only be described at the top level of a script, and their names must begin with a capital letter. Accessing a key that has no value on a package results in a panic. They may not be accessed using functions, only direct keyword access. Their type is :pkg.

They are written with the form pkg, then a package name, beginning with a capital letter, that will be bound as their name, and then an associative structure (pairs or word shorthands), delimited by {}, e.g.:

pkg Foo {
  :bar "bar"
  :baz 42
  quux
}

Working with collections

Ludus names are bound permanently and immutably. How do you add something to a list or a dict? How do you get things out of them?

Ludus provides functions that allow working with persistent collections. They're detailed in the Prelude. That said, all functions that modify collections take a collection and produce the modified collection as a return value, without changing the original collection. E.g., append ([1, 2, 3], 4) will produce [1, 2, 3, 4], but the original list is unchanged. (For dicts, the equivalent is assoc.)

Expressions

Ludus is an expression-based language: all forms in the language are expressions and return values, except panic!. That said, not all expressions may be used everywhere.

Toplevel expressions

Some expressions may only be used in the "top level" of a script. Because they are the toplevel, they are assured to be statically knowable. These include: pkg, ns, use, import, and test. (NB: not all of these are yet implemented.)

Non-binding expressions

Some forms may take any expression that does not bind a name, for example, any entry in a collection, or the right-hand side of a let binding. This is because binding a name in some positions is ambiguous, or nonsensical, or leads to unwarranted complications.

Simple expressions

Many compound forms will only accept "simple" expressions. Formally, simple expressions are either literal (atomic, string, or collection literals) or synthetic expressions. They are expressions which do not take sub-expressions: no if, when, match, etc. (do expressions are currently not simple, but that may be revised.)

Words and bindings

Ludus uses words to bind values to names. Words must start with a lower case ASCII letter, and can subsequently include any letter character (modulo backing character encoding), as well as , _, /, ?, !, and *.

Ludus binds values to names immutably and permanently: no name in the same scope may ever be re-bound to a different value. (Although see refs, below.

Attempting to use an unbound name (a word that has not had a value bound to it) will result in a validation error, and the script will not run.

let bindings: a very short introduction

Ludus's basic binding form is let:

let foo = :bar & `foo` is now bound to `bar` for the rest of the scope.

let foo = :baz & Validation error: name foo was bound in line 1

let bindings are more complex; we will return to these below.

Patterns

Ludus makes extensive use of pattern-matching. Patterns do two jobs at once: they match values (or don't); and they bind names. The left-hand side of the examples just above in the let binding is not just a word: it is a pattern. Patterns also arise in conditional forms and function declarations.

The placeholder: _

The simplest pattern is the placeholder: it matches against anything, and does not bind a name. It is written as a single underscore: _, e.g., let _ = :foo.

Ignored names

If you wish to be a bit more explict than using a placeholder, you can use an ignored name, which is a name that starts with an underscore: _foo. This is not bound, is not a valid name, and can be used however much you wish, even multiple times in the same pattern. It is, in fact, a placeholder, plus a reader-facing description.

Literal patterns

Patterns can be literal atomic values or strings: 0, false, nil, :foo, etc. That means you can write let 0 = 0 or let :foo = :foo, and, while nothing will happen, everything will be just fine.

Literals match against, well, literal values: if the pattern and the value are the same, they match! If not, they don't match.

Literal values do not bind anything.

Word patterns

Word patterns match against any value, and bind that value to the word as a name. The scope of that binding depends on the form the pattern is in. let foo = :bar binds :bar to foo for the rest of the scope.

Typed patterns

Word patterns can, optionally, take a type, using the as reserved word, and the keyword representing the desired type: let foo as :number = 42.

String patterns

Ludus has a simple but powerful form of string pattern matching that mirrors string interpolation. Any word inside curly braces in a string will match against a substring of a string passed into a pattern.

let i_am = "I am the walrus"

let "I {verb} the {noun}" = i_am
(verb, noun) &=> ("am", "walrus")

Note that such names may well be bound to empty strings: a match does not guarantee that there will be anything in the string. This is particularly relevant at the beginning and end of string patterns:

let we_are = "We are the eggmen"
let "{first}We {what}" = we_are
(first, what) &=> ("", "are the eggmen")

Collection patterns

Tuples, lists, and dicts can be destructured using patterns. They are written nearly identically to their literal counterparts. Collection patterns are composed of any number of simpler patterns or other collection patterns. They bind any names nested in them, match literals in them, etc.

Tuple patterns

Tuple patterns are delimited using parens, using commas or newlines to separate any number of other patterns. Consider let (x, y, z) = (1, 2, 3). x, y, and z are now bound to 1, 2, and 3, respectively.

The last item in a tuple pattern can be a splat--...--which either discards any remaining unenumerated values in a tuple, or binds them to a list. Without a splat, tuples patterns only match against tuples of the same length.

let mytup = (1, 2, 3)
let (x, _, y) = mytup & x is now 1, y is now 3
let (a, ...) = mytup & a is now 1; a bare splat (without a name) is just fine
let (_, ...cs) = mytup & cs is now [2, 3]
let (p, q) = mytup & panic! no match
let () = () & empty tuples are also patterns

List patterns

List patterns are identical to tuple patterns, but they are written using square braces. They also match against a specific number of members, and may take a splat in the last position, e.g. let [first, ...rest] = [1, 2, 3].

Note that list patterns, like tuple patterns, match on explicit length. That means that if you are matching only the first items of a list, you must explicitly include a splat pattern, e.g. let [first, second, ...] = [1, 2, 3, 4].

Dict patterns

Dict patterns are written either with shorthand words, or keyword-pattern pairs. Consider: let #{:a foo, :b 12, c} = #{:a 1, :b 12, :c 4}. foo is now 1; b is now 12, c is now 4. If a dict does not hold a value at a particular key, there is no match.

Dict patterns may also use a splat as their last member: let #{:a 1, ...b} = #{:a 1, :b 2, :c 3} will bind b to #{:b 2, :c 3}.

Like tuple and list patterns, dict patterns without a splat at the end match only on exact equivalence on all keys.

let bindings

let bindings are the basic form of matching and binding in Ludus. It is written let {pattern} = {non-binding expression}. The pattern can be arbitrarily complex. If the left-hand side of a let binding does not match, Ludus will raise a panic, halting evaluation of the script.

Scope and blocks

Ludus is lexically scoped. Bindings are valid for the remainder of the scope they act on. To introduce a new scope, Ludus uses a block, a collection of expressions delimited by curly braces and separated by semicolons or newlines. The value of a block, as well as the value of a script, is the last expression in it. In let foo = {:this; :set; :of; :expressions; "is actually"; :ignored }, foo will be bound to :ignored.

That said, you can use bindings in blocks, which will not be available outside that block--but blocks can use bidnings from their parent scope:

let outer = 42

let first = {
  let inner = 23
  add (outer, inner)
} & first is now bound to 65

inner & Validation error! unbound name inner

Shadowing

Even though names are bound permanently in Ludus, it is perfectly possible (and occasionally quite useful) to "shadow" names from an enclosing scope.

let x = 42

let y = {
  let first = x
  let x = 23 & this is fine
  let second = x
  add (first, second)
} & y is now 65

Conditional forms

Ludus has a robust set of conditional forms, all of which are expressions and resolve to a single value.

if

if evaluates a condition; if the result of the condition is truthy, it evaluates is then branch; if the condition is falsy, it evaluates its else branch. Both branches must always be present. Newlines may come before then and else.

if {simple expression} then {non-binding expression} else {non-binding expression}

when

when is like Lisp's cond: it takes a series of clauses, separated by semicolons or newlines, delimited by curly braces. Clauses are written lhs -> rhs. when expressions are extremely useful for avoiding nested ifs.

The left hand of a clause is a simple expression; the right hand of a clause is any expression. When the left hand is truthy, the right hand is evaluated, and the result of that evaluation is returned; no further clauses are evaluated. If no clause has a truthy left-hand value, then a panic is raised. In the example below, not the use of literal true as an always-matching clause.

when {
  maybe () -> :something
  mabye_not () -> :something_else
  true -> :always
}

match

A match form is the most powerful conditional form in Ludus. It consists of a test expression, and a series of clauses. The test expression must be a simple expression, followed by with, and then a series of clauses separated by a semicolon or newline, delimited by curly braces.

match may_fail () with {
  (:ok, value) -> calculate_result (value)
  (:err, msg) -> { log! (msg); recover_somehow () }
}

The left hand of a match clause is a pattern; the right hand is an expression: pattern -> expression. If the pattern matches the test expression of a clause, the expression is evaluated with any bindings from the pattern, and match form evaluates to the result of that expression.

If a test expression does not match against any clause's pattern, a panic is raised.

Ludus does not attempt to do any exhaustiveness checking on match forms; match errors are always runtime errors.

Functions

Ludus is an emphatically functional language. Almost everything in Ludus is accomplished by applying functions to values, or calling functions with arguments. (These are precise synonyms.)

Functions have the type :fn.

Calling functions

Functions are called by placing a tuple with arguments immediately after a function name, e.g. add (1, 2) adds 1 and 2. Because they are represented as tuples, arguments must be explicitly written; splats cannot be used to pass an arbitrary number of arguments to a function.

Defining functions

Functions have three increasingly complex forms to define them. All of them include the concept of a function clause, which is just a match clause whose left hand side must be a tuple pattern.

Anonymous lambda

An anonymous lambda is written fn {tuple pattern} -> {expression}, fn (x, y) -> if gt? (x, y) then x else add (x, y). Lambdas may only have one clause.

Named functions

A named function is identical to a lambda, with the one change that a word follows the fn reserved word: fn {name} {tuple pattern} -> {expression}. E.g., fn add_1 (x) -> add (x, 1). The name of the function is bound for the remainder of the scope.

Compound functions

Compound functions are functions that have multiple clauses. They must be named, and in place of a single clause after a name, they consist in one or more clauses, separated by semicolons or newlines, delimited by curly braces. Optionally, compound functions may have a docstring as their first element after the opening curly brace. The docstring explains the function's purpose and use, before any of the function clauses.

An exampele from Ludus's Prelude:

fn some {
	"Takes a possibly `nil` value and a default value. Returns the value if it's not `nil`, returns the default if it's `nil`."
	(nil, default) -> default
	(value, _) -> value 
}

Closures

Functions in Ludus are closures: function bodies have access not only to their specific scope, but any enclosing scope. Note that function bodies may have access to names bound after them in their scope, so long as the function is called after any names it accesses are bound.

Mutual recursion and forward declaration

If you try the following, you'll get a validation error:

fn stupid_odd? {
  (0) -> false
  (x) -> supid_even? (dec (x)) & Validation error: unbound name
}

fn stupid_even? {
  (0) -> true
  (x) -> stupid_odd? (dec (x))
}

To allow for mutual recursion, Ludus allows forward declarations, which are written fn name without any clauses. In the example above, we would simply put fn stupid_even? before we define stupid_odd?. If you declare a function without defining it, however, Ludus will raise a validation error.

The Prelude

The Prelude is a substantial set of functions that is available in any given Ludus script. (It is, itself, just a Ludus file that has special access to host functions.) Because of that, a large number of functions are always available. The prelude documentation is here.

Partial application

Functions in Ludus can be partially applied by using a placeholder in the arguments. Partial application may only use a single placeholder (partially applied functions are always unary), but it can be anywhere in the arguments: let add_1 = add(1, _) or let double = mult(_, 2).

Unary functions and called keywords may not be partially applied: it is redundant.

Because of "partial application," Ludus has a concept of an "argument tuple" (which may include a single placeholder) in addition to a tuple literal (which may not include a placeholder).

Function pipelines, or do forms

In place of nesting function calls inside other function calls, Ludus allows for a more streamlined version of function application: the do form or function pipeline. do is followed by an initial expression. do expressions use > as an operator: whatever is on the left hand side of the > is passed in as a single argument to whatever is on its right hand side. For example:

let silly_result = do 23 > 
  mult (_, 2) > add (1, _) > 
  sub (_, 2) > div (_, 9) & silly_result is 5

Newlines may appear after any instance of > in a do expression. That does, however, mean that you must be careful not to accidentally include any trailing >s.

Called keywords

Keywords may be called as functions, in which case they extract the value stored at that key in the value passed in:

let foo = #{:a 1, :b 2}
let bar = :a (foo) & `bar` is now 1

Called keywords can be used in pipelines.

In addition, keywords may be called when they are bound to names:

let foo = #{:a 1, :b 2}
let bar = :a
bar (foo) & => 1 

Synthetic expressions

Synthetic expressions are valid combinations of words, keywords, package names, and argument tuples which allow for calling functions and extracting values from associative collections. The root--first term--of a synthetic expression must be a word or a keyword; subsequent terms must be either argument tuples or keywords.

let foo = #{:a 1, :b #{:c "bar" :d "baz"}}

let bar = foo :b :c & `bar` is now "bar"

let baz = :b (foo) :d & `baz` is now "baz"

Looping forms

Ludus has optimized tail calls--the most straightforward way to accomplish repeating behaviour is function recursion. There are two additional looping forms, repeat and loop.

repeat

repeat is a help to learners, and is useful for executing side effects multiple times. It is written repeat {number|word} { {exprs} }. From turtle graphics:

repeat 4 {
  forward! (100)
  right! (0.25)
}

Note that repeat does two interesting things:

  1. It never returns a value other than nil. If it's in the block, it disappears. This is a unique (and frankly, undesirable) property in Ludus.
  2. Unlike everything else in Ludus, repeate requires a block, and not simply an expression. You cannot write repeat 4 forward! (100).

loop/recur

loop and recur are largely identical to recursive functions for repetition, but use a special form to allow an anonymous construction and a few guard rails.

The syntax here is loop <tuple> with { <function clauses> }. (Or, you can have a single function clause instead of a set of clauses.) The tuple is passed in as the first set of arguments.

let xs = [1, 2, 3, 4]
loop (xs, 0) with {
  ([x], sum) -> add (x, sum) & matches against the last element of the list
  ([x, ...xs], sum) -> recur (xs, add (x, sum)) & recurs with the tail
} &=> 10

recur is the recursive call. It must be in tail position--recur must be the root of a synthetic expression, in return position. If recur is not in tail position, a validation error will be raised.

In addition, recur calls must have the same number of arguments as the original tuple passed to loop. While Ludus will allow you to write clauses in loop forms with a different arity than the original tuple, those will never match.

recur calls return to the nearest loop. Nested loops are probably a bad idea and should be avoided when possible.

Environment and context: the toplevel

The "toplevel" of a script are the expressions that are not embedded in other expressions or forms: not inside a block, not a member of a collection, not on the right hand side of a binding, not inside a function body. The toplevel-only forms:

import

import allows for the evaluation of other Ludus scripts: import "path/to/file" as name. import just evaluates that file, and then binds a name to the result of evaluating that script. This, right now, is quite basic: circular imports are currently allowed but will lead to endless recursion; results are not cached, so each import in a chain re-evaluates the file; and so on.

Status: not yet implemented.

use

use loads the contents of a namespace into a script's context. To ensure that this is statically checkable, this must be at the toplevel.

Status: not yet implemented

pkg

Packages, pkges, may only be described at the toplevel of a script. This is to ensure they can be statically evaluatable.

test

A test expression is a way of ensuring things behave the way you want them to. Run the script in test mode, and these are evaluated. If the expression under test returns a truthy value, you're all good! If the expression under test returns a falsy value or raises a panic, then Ludus will report which test(s) failed.

test "something goes right" eq? (:foo, :foo)

test "something goes wrong" {
  let foo = :foo
  let bar = :bar
  eq? (foo, bar)
} &=> test failed: "something goes wrong" on line 3

tests must be at the toplevel--or embedded within other tests in their highest level.

Formally: test <string> <expression>.

Status: not yet implemented.

Changing things: boxes

Ludus does not let you re-bind names. It does, however, have a type that allows for changing values over time: box. A box is a place to put things, it has its own identity, it can store whatever you put in it.

Syntactically and semantically, boxes are straightforward, but do require a bit more overhead than let bindings. The idea is that Ludus makes it obvious where mutable state is in a program, as well as where that mutable state may change. It does so elegantly, but with some guardrails that may take a little getting used to.

The type of a box is, predictably, :box.

box foo = 42 & foo is now bound to a _box that contains 42_
add (1, foo) & panic! no match: foo is _not_ a number
store! (foo, 23) & foo is now a box containing 23
update! (foo, inc) & foo is now a ref containing 24
unbox (foo) &=> 23; use unbox to get the value contained in a box

Ending with a bang!

Ludus has a strong naming convention that functions that change state or could cause an explicit panic end in an exclamation point (or, in computer nerd parlance, a "bang"). So anything function that mutates the value held in a reference ends with a bang: store! and update! take bangs; unbox does not.

This convention also includes anything that prints to the console: print!, report!, doc!, update!, store!, etc.

(Note that there are a few counter-examples to this: math functions that could cause a panic [in place of returning NaN] do not end with bangs: div, inv, and mod; each of these has variants that allow for more graceful error handling).

Ending with a whimper?

Relatedly, just about any function that returns a boolean value is a predicate function--and has a name that ends with a question mark: eq? tests for equality; box? tells you if something is a ref or not; lte? is less-than-or-equal.

Errors: panic! in the Ludus script

A special form, panic!, halts script execution with the expression that follows as an error message.

panic! :oops

if true then :ok else panic! "It's false!"

Panics also happen in the following cases:

  • a let binding pattern has no match against the value of its expression
  • a match or when form has no matching clause
  • a function is called with arguments that do not match any of its clauses
  • something that is not a function or keyword is called as a function
  • a called keyword is partially applied
  • div, inv, or mod divides by zero
  • sqrt takes the square root of a negative number
  • certain error handling functions, like unwrap! or assert!, are invoked on values that cause them to panic

In fact, the only functions in the Prelude which explicitly cause panics are, at current, div, inv, mod, sqrt, unwrap!, and assert!.

nils, not errors

Ludus, however, tries to return nil instead of panicking a lot of the time. So, for example, attempting to get access a value at a keyword off a number, while nonsensical, will return nil rather than panicking:

let a = true
a :b :c :d :e &=> nil

let b = [1, 2, 3]
at (b, 12) &=> nil

Result tuples

Operations that could fail--especially when you want some information about why--don't always return nil on failures. Instead of exceptions or special error values, recoverable errors in Ludus are handled instead by result tuples: (:ok, value) and (:err, msg). So, for example, unwrap! takes a result tuple and either returns the value in the :ok case, or panics in the :err case.

Variants of some functions that may have undesirably inexplicit behaviour are written as {name}/safe. So, for example, you can get a variant of div that returns a result tuple in div/safe, which returns (:ok, result) when everything's good; and (:err, "division by zero") when the divisor is 0.