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
pairs and windows come from the standard library; `< is < taking one pair as its two arguments.| Input | Output | |
|---|---|---|
1:10 | filter (even) | sum | 30 | a 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 | 5 | 60 | a 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 3 | Evaluation 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
<- 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)passesfiltera 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.