Cassette is a small, Lisp-like programming language. It looks like this:

import List
import Math
import Canvas
import System

let width = 800,
    height = 480,
    canvas = Canvas.new(width, height)

canvas.text("Lines!", {200, 2})

System.seed(System.time())

def rand-line(i) do
  let x0 = Math.floor(i * width / 100),
      y0 = Math.rand-int(20, height / 10),
      x1 = Math.rand-int(0, width),
      y1 = Math.rand-int(20, height)
  canvas.line({x0, y0}, {x1, y1})
end

List.map(\i -> rand-line(i), List.range(0, 100))

Press Play

I made Cassette as a simple language for “playful programming”. Playful programming is writing something for the sake of writing it. It’s making a software 3D renderer or a GIF reader, even though better implementations of those already exist. It’s making generative art programs and drawing them with a pen plotter. Cassette itself is playful programming—there are certainly other scripting languages that may be better for personal projects like these, but this one is mine.

Here are some of the design goals of Cassette:

  • Functional
  • Immutable types
  • Simplicity over efficiency
  • Small implementation
  • Few dependencies

Here’s some future work for Cassette:

  • Bignum support
  • Generational garbage collection
  • Compiler & VM optimization
  • Support other backends (WebAssembly, LLVM)
  • Destructuring assignment (v2?)
  • Pattern-based function dispatch (v2?)

Getting Started

This project requires a C build toolchain and SDL2. The source code can be found here.

  1. Get the project’s dependencies
    • On macOS with Homebrew, run brew install llvm git sdl2 sdl2_ttf
    • On Debian, run apt install build-essential clang git libsdl2-dev libsdl2-ttf-dev libfontconfig-dev
  2. Clone the repo with git clone https://git.sr.ht/~zjm/Cassette.
  3. Run make to build the project. This creates the executable cassette.
  4. Optionally, run make install to install the Cassette executable. You can set the install folder in the Makefile.
  5. Try the example with ./cassette test/test.ct.
  6. Write a little script and run it with ./cassette script.ct.

Syntax

Cassette has two number types, integers and floats. Integers can be written in decimal, hexadecimal, or as a character. The normal infix arithmetic operations work on numbers, and bitwise operations work on integers.

-1

1                 ; decimal integer
0x1F              ; hex integer
$a                ; => 0x61
1.0               ; float

-4                ; => -4
1 + 2             ; => 3
5 * 5             ; => 25
10 / 2            ; => 5.0
12 % 10           ; => 2

0xF0 >> 4         ; => 0x0F (shift right)
0x55 << 1         ; => 0xAA (shift left)
0x37 & 0x0F       ; => 0x07 (bitwise and)
20 ^ 1            ; => 21   (bitwise or)
~4                ; => 3    (bitwise not)

Cassette has symbols, which represent an arbitrary value. true and false are symbols. In boolean operations, all values are truthy except false and nil. Comparison operators only work on numbers, but equality operators work on any type. The and and or operators are short-circuiting.

:ok
:not_found

3 > 1             ; => true
3 < 3             ; => false
5 <= 4 + 1        ; => true
5 >= 4 + 1        ; => true
:ok == :ok        ; => true
:ok != :ok        ; => false
3 > 1 and 4 == 5  ; => false
3 > 1 or 4 == 5   ; => true
3 >= 0 and 3 < 5  ; => true
nil and :ok       ; => false
:error and :ok    ; => true

Strings are UTF-8 encoded binaries. You can find the length of a string, concatenate two binaries, and test if one string or byte is present in another string. Binaries also represent other arbitrary byte sequences, such as the contents of a file.

"Hello!"
#"Hello!"         ; => 6
#""               ; => 0

"Hi " <> "there"  ; => "Hi there"
"foo" <> "bar"    ; => "foobar"

"ob" in "foobar"  ; => true
$r in "foobar"    ; => true
65 in "Alpha"     ; => true
1000 in "foobar"  ; error: bytes must be between 0–255

Cassette has Lisp-style cons pairs, which form linked lists. Pairs are formed with the | operator, and can be used to easily prepend values to a list. nil is the empty list. You can find the length of a list, concatenate two lists, and test if a value is in a list. Lists can be accessed with an integer index.

[1, 2, 3]         ; list
[1, 2, 3].2       ; => 3
[1, 2, 3].8       ; error: out of bounds
[:a, x, 42]
nil == []         ; => true

1 | [2, 3, 4]     ; => [1, 2, 3, 4]
3 | nil           ; => [3]
1 | 2 | 3 | nil   ; => [1, 2, 3]

#[1, 2, 3]        ; => 3
#nil              ; => 0
[1, 2] <> [3, 4]  ; => [1, 2, 3, 4]
:ok in [:ok, 3]   ; => true

Cassette has tuples, which are fixed-length arrays of values. Tuples are less flexible than lists, but use less memory and are a little faster to access. You can find the length of a tuple, concatenate two tuples, and test if a value is in a tuple. Tuples can be accessed with an integer index.

{1, 2, 3}         ; tuple
{1, 2, 3}.2       ; => 3
{1, 2, 3}.8       ; error: out of bounds
#{1, 2, 3}        ; => 3
#{}               ; => 0
{1, 2} <> {3, 4}  ; => {1, 2, 3, 4}
"x" in {"y", "z"} ; => false

Cassette has maps (a.k.a. dictionaries), which can be written like tuples with symbol keys. Map literals can only have symbol keys, but other functions can get and set keys of other types. You can find the number of key/value pairs in a map, merge two maps, and test if a map contains a key.

{x: 3, y: 4}      ; a map with keys `:x` and `:y`
my_map.x          ; => 3
my_map.z          ; => nil

#{x: 1, y: 2}     ; => 2
{x: 1, z: 4} <> {x: 2, y: 3}
                  ; => {x: 2, y: 3, z: 4}
:x in {x: 1, y: 2}  ; => true

Variables are defined with let. A do block can introduce a new scope, and can be used to combine a group of expressions into one. Cassette has lexical scoping. A variable must start with a letter or underscore, but may contain any characters afterward except for whitespace and these reserved characters: ;,.:()[]{}. This means that if you want to write an infix expression, you must often include space around the operator to disambiguate it from its operands.

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

print(x - y)     ; prints "-1"
print(x-y)       ; prints "3"

do
  let y = 3, z = 4

  x       ; => 1 (from the parent scope)
  y       ; => 3 (shadows the parent `y`)
  z       ; => 4
end

x         ; => 1
y         ; => 2
z         ; error: undefined variable

Cassette has if/else blocks and cond blocks for conditionals. A cond block will evaluate each predicate until one is true, then evaluate that clause.

if x == 0 do
  IO.print("Uh oh!")
  :error
else
  :ok
end

cond do
  x > 10    -> :ten
  x > 1     -> :one
  true      -> :less  ; default
end

Lambdas can be created with a backslash, argument list, and an arrow. The def syntax is syntactic sugar for defining a lambda as a variable, with the distinction that def-declared functions have block scope, so they can be called recursively. Functions are called with parentheses.

let foo = \x -> x + 1
foo(3)            ; => 4

; equivalent, except for scope:
let foo = \x -> x + 1
def foo(x) x + 1

; these produce an error, since `b` isn't defined when the body of `a` is compiled
let a = \x -> b(x * 3),
    b = \x -> a(x / 2)

; these are ok, since `a` and `b` are in scope from the beginning of the block
def a(x) b(x * 3)
def b(x) a(x / 2)

Cassette programs can be split up into different modules, one per file. Modules can be imported directly into a file’s scope, or aliased as a map.

; file "foo.ct"
module Foo

let pi = 3.14
def bar(x) x + 1
; file "main.ct"
import Foo        ; imported as a map called `Foo`

Foo.bar(3)        ; => 4
Foo.pi            ; => 3.14
; alternative "main.ct"
import Foo as F   ; imported as a map called `F`

def bar(x) x + 8

bar(3)            ; => 11
F.bar(3)          ; => 4
; alternative "main.ct"
import Foo as *   ; imported directly into current scope

bar(3)            ; => 4
pi                ; => 3.14

More Info

For more information about Cassette, check out some of these other documents. Stay tuned for future articles.