Crossbow

A small, dynamically typed programming language for solving puzzles at an interactive prompt, written in Haskell in late 2021. A program is a pipeline: each clause takes the previous result as its next argument, functions are curried, and a list of functions applies element-wise, so most programs need no variables at all.

Its README calls it an experimental personal project, not intended for general use, and that is the spirit of this page: how it reads and how it runs, with every output below produced by the interpreter, built from the repository with GHC 9.10.

Reading Crossbow

A pipeline, stage by stage Two pipelines over the list [3, 5, 4, 6, 9], with each intermediate value as Crossbow prints it. The first, pairs then count with a backtick-uncurried <, turns the list into neighbouring pairs [[3,5],[5,4],[4,6],[6,9]] and counts the pairs that increase: 3. The second takes sliding windows of three, [[3,5,4],[5,4,6],[4,6,9]], sums each with sum! (map sum), [12,15,19], and then counts increases the same way: 2. [3, 5, 4, 6, 9] [[3,5],[5,4],[4,6],[6,9]]| pairs 3| count `< [3, 5, 4, 6, 9] [[3,5,4],[5,4,6],[4,6,9]]| windows 3 [12,15,19]| sum! 2| pairs | count `<
Data flows left to right. pairs and windows come from the standard library; `< is < taking one pair as its two arguments.
Each line run by the interpreter (scripts/site/capture.py)
InputOutput
1:10 | filter (even) | sum30a pipeline: each clause takes the previous result
[+ 1, + 2] | [3, 4][4,6]a list of functions applies element-wise
{$0 | sum | + $1} | 1:10 | 560a lambda with two arguments
enum 1:10[[0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],[7,8],[8,9],[9,10]]enum, from the standard library
[[1,2],[3,4]] | map (`+)[3,7]`+ takes one list of two arguments
[[1,2],[3,4]] | sum![3,7]sum! is map sum
[1, "hi", [True]][1,"hi",[True]]lists may mix types
_2 [10, 20, 30]30_n takes the item at index n
1:3 | fork 2[[1,2,3],[1,2,3]]fork copies a value
nosuchthing 3Evaluation error: "No value named: nosuchthing"an unknown name is a clean error

Advent of Code in a few lines

Crossbow was written to solve Advent of Code, and test/aoc.cb solves the first days of 2021. Day 1 is the pipeline in the figure above, applied to the puzzle input: aoc 2021 1 reads the day's input, ints parses one number per line, and each part is one line.

Day 2 shows the other tools: <- binds a name, case picks a branch by the command's first letter, ! maps a function over a list, and fold threads a three-part state through the commands. Day 3 defines its own higher-order helpers, favouring and step, as lambdas.

Running this file prints 8 answers in 3.5 seconds (the answers depend on personal puzzle inputs, so they are not shown).

# Day 1

# Part 1
aoc 2021 1 | ints | pairs | count `<

# Part 2
aoc 2021 1 | ints | windows 3 | sum! | pairs | count `<


# Day 2
sub <- {case | head $0 | [['u', negate [0, _1 $0]], ['d', [0, _1 $0]], ['f', [_1 $0, 0]]]}
d2 <- (aoc 2021 2 | lines | {words | second int | first fst}! | sub!)

# Part 1
d2 | sum | `*

# Part 2
d2 | fold {$0 | [+ (fst $1), + (* (fst $1) (thd $0)), + (snd $1)]} [0, 0, 0] | take 2 | `*


# Day 3
d3 <- (aoc 2021 3 | lines | int!!)
favouring <- {f <- $0 | m <- f $2 | $2 | counts | snd! | `== | if | $1 | m}
step <- {i <- $2 | map (_i) $1 | $0 | m <- | filter {== | m | _(i) $0} $1}

# Part 1
d3 | transpose | fork 2 | [mode!, antimode!] | bits! | `*

# Part 2
[((mode) favouring 1), ((antimode) favouring 0)] | {fold | step $0 | d3 | 0:11}! | head! | bits! | `*

The standard library is Crossbow

Only the primitives are written in Haskell: arithmetic, foldl, map, filter, zip, fork, if and so on. Everything else is defined in lib.cb, loaded before your first line, in Crossbow itself. {…} is a lambda whose arguments are $0, $1, …, and a lambda that never mentions an argument is fed the pipeline's value as $0.

enum shows the intended style: fork the list into two copies, turn one into its indices, and zip them, with no names for intermediate values. pairs zips a list with itself shifted by one; windows takes the tails, transposes them and trims the ragged end.

# Basic
id <- {null|$0}
const <- {$1|null|$0}

# Parsing and IO
aoc <- {(((("test/aoc_input/" ++ (string $0)) ++ "/") ++ (string $1)) ++ ".txt") | read}
ints <- {lines|int}

# Bools and Predicates
not <- {if | $0 | False | True}
odd <- {mod $0 2|bool}
even <- {odd $0|not}
lengthy <- {$1|length|(== $0)}

# Numerical
min <- {if | ($0 < $1) | $0 | $1}
max <- {if | ($0 > $1) | $0 | $1}

# List Operations
fold <- foldl
scan <- scanl
head <- ix 0
tail <- drop 1
foldl1 <- {foldl | $0 | head $1 | tail $1}
scanl1 <- {scanl | $0 | head $1 | tail $1}
reverse <- {foldl | flip cons | [] | $0}
length <- {map (const 1) | sum}
sum <- {foldl|+|0|$0}
minOn <- {map $0 [$1, $2]|monadic <= |if|$1|$2}
maxOn <- {map $0 [$1, $2]|monadic > |if|$1|$2}
maximum <- foldl1 max
minimum <- foldl1 min
maximumOn <- {$1 | foldl1 (maxOn $0)}
minimumOn <- {$1 | foldl1 (minOn $0)}
count <- {filter $0 $1 | length}
mode <- {counts|maximumOn snd|fst}
antimode <- {counts|minimumOn snd|fst}
pairs <- {$0|fork 2|[id, drop 1]|monadic zip}
square <- {$0 | fork (length $0)}
enum <- {$0|fork 2|[length,id]|[range 0, id]|monadic zip}
windows <- {$1|tails|take $0|transpose|take ((1 + length $1) - $0)}

# Accessor / Setter Aliases
first <- nap 0
second <- nap 1
third <- nap 2
fst <- ix 0
snd <- ix 1
thd <- ix 2

How it runs

How a line runs How the interpreter runs a line. The parser, written with Parsec, splits the line into clauses at each | and parses each clause into values: numbers, strings, lists, ranges, functions with some arguments supplied, lambdas in braces whose arguments are $0, $1 and so on, f! for map, _n for indexing, and `f for a function taking one list of arguments. The evaluator runs the clauses left to right, feeding each result into the next clause as its next argument. Every name is looked up in one table: it starts with the built-in functions written in Haskell, and each binding made with <- adds a name or replaces one, beginning with the standard library lib.cb, which is itself Crossbow. a line of text1:10 | filter (even) | sum parserParsec: clauses split at | clauses of valuesfunctions, lambdas, lists… evaluatoreach result → next clause Haskell builtinsthe table starts here <- bindingslib.cb, then yours one name tablea binding adds or replaces names
One table of names. A binding made with <- can replace a built-in of the same name.

The interpreter is about 1,400 lines of Haskell: a Parsec parser, a value type with integers, doubles, booleans, characters, lists, sets, maps, functions and lambdas, and an evaluator in a state monad that holds the name table. crossbow repl starts a prompt with tab completion over the known names; crossbow FILE runs a file and prints each line's result.

Rough edges

  • Errors in built-ins end the program. A built-in given the wrong kind of value hits a non-exhaustive pattern in its Haskell implementation and the interpreter exits, REPL included. The README's own filter (even) (sum 1:10) passes filter a number:
filter (even) (sum 1:10)
crossbow: src/Crossbow/Evaluator.hs:(407,19)-(415,79): Non-exhaustive patterns in function filter
  • The REPL does not stop at end of input: on Ctrl-D it keeps prompting.
  • Weak typing has no guard rails: mixed lists are legal, and most type mistakes surface as one of the crashes above rather than a message.