This document walks through the basics of using the Unison codebase manager and writing Unison code. We will introduce bits and pieces of the core Unison language and its syntax as we go. The Unison language documentation is a more in-depth resource if you have questions or want to learn more.
If you want to follow along with this document (highly recommended), this guide assumes you've already gone through the steps inthe quickstart guideand skimmed throughthe big idea.
👋 to the Unison codebase manager
The Unison Codebase Manager, or UCM for short, is the command line tool that runs the Unison programming language and allows you to interact with the Unison code you've written and saved. Put differently, the UCM is the interface to your Unison codebase.
💡 Remember: Unison code is not saved as text-based file content. Because of this, we need a tool that lets us change and run Unison programs.
Its many responsibilities include:
- Typechecking and compiling new code
- Organizing, navigating, and finding Unison definitions
- Storing the state of your codebase
- Running Unison programs and Unison binaries
- Publishing and pulling Unison libraries
🎉 Running the UCM
By default, runningucm
in a directory will interact with any.u
suffixed file in the directory where the command was issued while opening the default codebase in your home directory. You'll get a message in the UCM like:
Now starting the Unison Codebase Manager (UCM)...
[…]
Get started:
📖 Type help to list all commands, or help <cmd> to view help for one command
🎨 Type ui to open the Codebase UI in your default browser
📚 Read the official docs at https://www.unison-lang.org/learn/
🌏 Visit Unison Share at https://share.unison-lang.org to discover libraries
👀 I'm watching for changes to .u files under /Users/rebeccamark/Unison/website3/website
.>
What's happening here? This is the Unison Codebase Manager starting up and initializing a fresh codebase. We're used to thinking about our codebase as a bag of text files that's mutated as we make changes to our code, but in Unison the codebase is represented as a collection of serialized syntax trees, identified by a hash of their content and stored in a collection of files inside of a.unison
directory in the path you supplied to the ucm.
The Unison codebase format has a few key properties:
- It isappend-only:once a file in the
.unison
directory is created, it is never modified or deleted, and files are always named uniquely and deterministically based on their content. - As a result, a Unison codebase can be versioned and synchronized with Git or any similar tool and will never generate a conflict in those tools.
The codebase location the UCM drops you in if you have never used it before is represented by the.
in the prompt,.>
.We call this "the root" of your codebase.
Rather than creating multiple codebases for each application you're working on, Unison subdivides the codebase into "projects". We'll introduce the concept of projects by creating one for thistour
and establishing some conventions for organizing it.
Unisonprojectsare analogous to source code repositories. They help organize your codebase into applications, libraries, and other work that you may want to collaborate with others on or shrare. Projets are further divided intobranchesfor representing independent work streams.
Inside a project, your code is further organized into Unisonnamespaces.Namespaces are mappings from human-readable names to their definitions. Names in Unison are things like:math.sqrt
,base.Optional.Some
,base.Nat
,base.Nat.*
,++
,orfoo
.That is: an optional.
,followed by one or more segments separated by a.
,with the last segment allowed to be an operator name like*
or++
.
We often think of these name segments as forming a tree, much like a directory of files where names are like file paths in the tree. It's most common to work with your UCM prompt at the root of the project, but you can also navigate through the namespace tree with thenamespace
(or equivalentlycd
)command.
In the codebase manager console, create atour
project with theproject.create
command. This is where you'll be adding code for the remainder of this guide.
.> project.create tour
🎉 I've created the project tour.
I'll now fetch the latest version of the base Unison library...
🎨 Type `ui` to explore this project's code in your browser.
🌏 Discover libraries at https://share.unison-lang.org
📖 Use `help-topic projects` to learn more about projects.
Write your first Unison code with UCM:
1. Open scratch.u.
2. Write some Unison code and save the file.
3. In UCM, type `add` to save it to your new project.
🎉 🥳 Happy coding!
tour/main>
Notice the prompt changes totour/main>
,indicating your current project is nowtour
and your current branch is/main
.When editing Unison code, and interacting with the UCM your UCM commands and code are "scoped" to this project and branch unless otherwise indicated with anabsolute path.
When you create a new project, the UCM automatically installs thebase
standard library for you. It's located in a special namespace calledlib
.
Let's explore thebase
library that was just downloaded and get used to navigating a Unison codebase.
You can view the terms and types in a namespace with thels
ucm command.
tour/main> ls lib.base.data.List
The output should be a numbered list of definitions and their associated signatures.
tour/main> ls lib.base.data.List
1. ++ ([a] -> [a] -> [a])
2. +: (a -> [a] -> [a])
3. :+ ([a] -> a -> [a])
4. Nonempty (type)
[…]
Because of the append-only nature of the codebase format, we can cache all sorts of interesting information about definitions in the codebase andnever have to worry about cache invalidation.For instance, Unison is a statically-typed language and we know the type of all definitions in the codebase, so one thing that's useful and easy to maintain is an index that lets us search for definitions in the codebase by their type. Try out the following twofind
commands (new syntax is explained below):
tour/main> find reverse
1. lib.base.data.List.Nonempty.reverse : List.Nonempty a -> List.Nonempty a
2. lib.base.data.List.reverse : [a] -> [a]
3. lib.base.Text.reverse : Text -> Text
4. lib.base.data.List.Nonempty.reverse.doc : Doc
Thefind
command here is searching for definitions whose names includereverse
.It searches first within our own code in the project, and then in the dependencies inlib
.
tour/main> find : [a] -> [a]
1. lib.base.data.deprecated.Heap.sortDescending : [a] -> [a]
2. lib.base.data.deprecated.Heap.sort : [a] -> [a]
3. lib.base.data.List.distinct : [a] -> [a]
4. lib.base.data.List.sort : [a] -> [a]
5. lib.base.data.List.dropLast : [a] -> [a]
6. lib.base.data.List.reverse : [a] -> [a]
tour/main> view 6
lib.base.data.List.reverse : [a] -> [a]
lib.base.data.List.reverse as = List.foldLeft (acc a -> a +: acc) [] as
Here, we did a type-based search, withfind
followed by a colon,:
,to search for functions of type[a] -> [a]
.We got a list of results, and then used theview
command to look at the nicely formatted source code of one of these results. Let's introduce some Unison syntax:
List.reverse : [a] -> [a]
is the syntax for giving a type signature to a definition. We pronounce the:
symbol as "has type", as in reverse has the type[a] -> [a]
.[Nat]
is the syntax for the type consisting of lists of natural numbers (terms like[0, 1, 2]
and[]
will have this type), and more generally[Foo]
is the type of lists whose elements have some typeFoo
.- Any lowercase variable in a type signature is assumed to beuniversally quantified,so
[a] -> [a]
really means and could be writtenforall a . [a] -> [a]
,which is the type of functions that take a list whose elements are some type, and return a list of elements of that same type. List.reverse as
takes one parameter, calledas
.The stuff after the=
is called thebodyof the function, and here it's ablock,which is demarcated by whitespace.acc a -> ..
is the syntax for an anonymous function.- Function arguments are separated by spaces and function application binds tighter than any operator, so
f x y + g p q
parses as(f x y) + (g p q)
.You can always use parentheses to control grouping more explicitly.
Names are stored separately from definitions so renaming is fast and 100% accurate
The Unison codebase, in its definition forList.reverse
,doesn't store names for the definitions it depends on (like theList.foldLeft
function); it references these definitions via their hash. As a result, changing the name(s) associated with a definition is easy.
Let's try this out.List.reverse
is defined usingList.foldLeft
.Let's rename that toList.foldl
to make it more familiar to Haskell fans. Try out the following command (you can use tab completion here if you like):
tour/main> move.term lib.base.data.List.foldLeft lib.base.data.List.foldl
Done.
tour/main> view lib.base.data.List.reverse
lib.base.data.List.reverse : [a] -> [a]
lib.base.data.List.reverse as =
use base.data.List +:
base.data.List.foldl (acc a -> a +: acc) [] as
Notice thatview
shows thefoldl
name now, so the rename has taken effect. Nice!
To make this happen, Unison just changed the name associated with the hash ofList.foldLeft
in one place.Theview
command looks up the names for the hashes on the fly, right when it's printing out the code.
So rename and move things around as much as you want! Don't worry about picking a perfect name the first time. Give the same definition multiple names if you so choose! Naming things is hard enough, renaming them shouldn't be a trial.
The fact that Unison codebases are immutable and append-only means that we can "rewind" our codebase to an earlier point in time. Use thereflog
command to see a log of the codebase changes. You should see some help text and a numbered list of hashes.
1. #2cbugd57qa : move.term lib.base.data.List.foldLeft lib.base.data.List.foldl
2. #na6fel77ai : project.create tour
3. #sjg2v58vn2 : (initial reflogged namespace)
Reflog keeps track of the history of the codebase by recording the hash of the rootnamespaceof your entire codebase. Namespace hashes change along with updates to the term and type definitions that they enclose. When we renamedList.foldLeft
,conceptually, the "state" of the codebase changed, but the log-based format of the codebase history means those changes are retrievable.
Let's try to undo the rename action. Use thereset-root
command to pick a prior codebase state to return to. We'll give it the hash of the codebase from just before themove.term
command was issued.
tour/main> reset-root #na6fel77ai
Done.
Great! OK, go drink some water, 🚰 and then let's start writing some Unison code!
Unison's interactive scratch files
The codebase manager lets you make changes to your codebase and explore the definitions it contains, but it also listens for changes to any file ending in.u
in the current directory. When any such file is saved (which we call a "scratch file"), Unison parses and typechecks that file. Let's try this out.
Keep yourucm
terminal running and open up a file,scratch.u
(orfoo.u
,or whatever you like) in your preferred text editor (if you want syntax highlighting for Unison files,follow this linkfor instructions on setting up your editor).
Now put the following in your scratch file:
use base
square : Nat -> Nat
square x =
use Nat *
x * x
This defines a function calledsquare
.It takes an argument calledx
and it returnsx
multiplied by itself.
The first line,use base
,tells Unison that you want to use short names for the base libraries in this file (which allows you to sayNat
instead of having to saybase.Nat
).The UCM will prefer thebase
instance found inlib
.
When you save the file, Unison replies:
✅
I found and typechecked these definitions in ~/unisoncode/scratch.u. If you do an
`add` or `update` , here's how your codebase would change:
⍟ These new definitions are ok to `add`:
square : base.Nat -> base.Nat
Now evaluating any watch expressions (lines starting with `>`)… Ctrl+C cancels.
It typechecked thesquare
function and inferred that it takes a natural number and returns a natural number, so it has the typeNat -> Nat
.It also tells us thatsquare
is "ok toadd
."We'll do that shortly, but first, let's try calling our function right in thescratch.u
file, just by starting a line with>
:
use base
square : Nat -> Nat
square x = x * x
> square 4
And Unison prints:
6 | > square 4
⧩
16
That6 |
is the line number from the file. The> square 4
on line 6 of the file, starting with a>
is called a "watch expression", and Unison uses these watch expressions instead of having a separate read-eval-print-loop (REPL). The code you are editing can be run interactively as you go, with a full text editor at your disposal, with the same definitions all in scope, without needing to switch to a separate tool.
Theuse base
is awildcard use clause.This lets us use anything from thebase
namespace under thelib
namespace unqualified. For example we refer tobase.Nat
as simplyNat
.
Question:do we really want to reevaluate all watch expressions on every file save? What if they're expensive? Luckily, Unison keeps a cache of results for expressions it evaluates, keyed by the hash of the expression, and you can clear this cache at any time without ill effects. If a result for a hash is in the cache, Unison returns that instead of evaluating the expression again. So you can think of and use your.u
scratch files a bit like spreadsheets, which only recompute the minimal amount when dependencies change.
Let's try out a few more examples:
-- A comment, ignored by Unison
> List.reverse [1,2,3,4]
> 4 + 6
> 5.0 / 2.0
> not true
✅
~/unisoncode/scratch.u changed.
Now evaluating any watch expressions (lines starting with
`>`)… Ctrl+C cancels.
6 | > List.reverse [1,2,3,4]
⧩
[4, 3, 2, 1]
7 | > 4 + 6
⧩
10
8 | > 5.0 / 2.0
⧩
2.5
9 | > not true
⧩
false
Testing your code
Let's add a test for oursquare
function:
square : Nat -> Nat
square x = x * x
test> square.tests.ex1 =
use Nat ==
check (square 4 == 16)
Save the file, and Unison comes back with:
8 | test> square.tests.ex1 = check (square 4 == 16)
✅ Passed : Proved.
Some syntax notes:
- The
test>
prefix tells Unison that what follows is a test watch expression. Note that we're also giving a name to this expression,square.tests.ex1
. - There's nothing special about the name
square.tests.ex1
;we could call those bindings anything we wanted. Here we use the convention that tests for a definitionfoo
go infoo.tests
.
Thetest.check
function has the signaturetest.check : Boolean -> [test.Result]
.It takes aBoolean
expression and gives back a list of test results, of type[base.test.Result]
(tryview test.Result
).In this case there was only one result, and it was a passed test.
A property-based test
Let's test this a bit more thoroughly.square
should have the property thatsquare a * square b == square (a * b)
for all choices ofa
andb
.The testing library supports writing property-based tests like this. There's some new syntax here, explained afterwards:
use base
square : Nat -> Nat
square x = x * x
use test
test> square.tests.ex1 = check (square 4 == 16)
test> square.tests.prop1 =
go _ = a = !gen.natInOrder
b = !gen.natInOrder
expect (square a * square b == square (a * b))
runs 100 go
11 | go _ = a = !natInOrder
✅ Passed : Passed 100 tests.
This will test our function with a bunch of different inputs.
Syntax notes
- The Unison block, which begins after an
=
,can have any number ofbindings(likea = …
)all at the same indentation level, terminated by a single expression (hereexpect (square ..)
),which is the result of the block. - You can call a function parameter
_
if you just plan to ignore it. Here,go
ignores its argument; its purpose is just to makego
lazily evaluatedso it can be run multiple times by theruns
function. natInOrder
is adelayed computation.A delayed computation is one in which the result is not computed right away. The signature for a delayed computation can be thought of as a function with no arguments, returning the eventual result:() -> a
.The!
in!gen.natInOrder
evaluates the delayed computation, summoning a value of typeNat
.natInOrder
comes fromtest
-test.gen.natInOrder
.It's a "generator" of natural numbers.!natInOrder
generates one of these numbers starting at 0 and incrementing by one each time it is called.
Adding code to the codebase
Thesquare
function and the tests we've written for it are not yet part of the codebase. So far they only exist in our scratch file. Let's add them now. Switch to the UCM and typeadd
.You should get something like:
tour/main> add
⍟ I've added these definitions:
square : Nat -> Nat
square.tests.ex1 : [Result]
square.tests.prop1 : [Result]
You've just added a new function and some tests to your Unison codebase under thetour
namespace. Try typingview square
orview square.tests.prop1
.Notice that Unison inserts preciseuse
statements when rendering your code.use
statements aren't part of your code once it's in the codebase. When rendering code, a minimal set ofuse
statements is inserted automatically by the code printer, so you don't have to be precise with youruse
statements.
If you typetest
at the Unison prompt, it will "run" your test suite:
tour/main> test
Cached test results (`help testcache` to learn more)
◉ square.tests.ex1 : Proved.
◉ square.tests.prop1 : Passed 100 tests.
✅ 2 test(s) passing
Tip: Use view square.tests.ex1 to view the source of a test.
But actually, it didn't need to run anything! All the tests had been run previously and cached according to their Unison hash. In a purely functional language like Unison, tests like these are deterministic and can be cached and never run again. No more running the same tests over and over again!
Moving and renaming terms
When we addedsquare
,we were at thetour
namespace, sosquare
and its tests are attour.square
.We can also move the terms and namespaces to different locations in our codebase with themove
commands.
tour/main> move.term square mySquare
Done.
tour/main> find
1. mySquare : base.Nat -> base.Nat
tour/main> move.namespace square.tests mySquare.tests
Done.
When you're done shuffling some things around, you can usefind
with no arguments to view all the definitions under the current namespace:
tour/main> find
1. mySquare : Nat -> Nat
2. mySquare.tests.ex1 : [Result]
3. mySquare.tests.prop1 : [Result]
Also notice that we don't need to rerun our tests after this reshuffling. The tests are still cached:
tour/main> test
Cached test results (`help testcache` to learn more)
◉ mySquare.tests.ex1 : Proved.
◉ mySquare.tests.prop1 : Passed 100 tests.
✅ 2 test(s) passing
Tip: Use view square.tests.ex1 to view the source of a test.
We get this for free despite the renaming because the test cache is keyed by the hash of the test, not by what the test is called.
When you're starting out writing some code, sometimes it's nice to put it in a temporary namespace, perhaps calledtemp
orscratch
.Later, without breaking anything, you can move that namespace or bits and pieces of it elsewhere, using themove.term
,move.term
,andmove.namespace
commands.
Modifying existing definitions
Instead of starting a function from scratch, often you just want to slightly modify something that already exists. Here we'll make a change to the implementation of ourmySquare
function.
Using theedit
command
Try enteringedit mySquare
in the UCM:
tour/main> edit mySquare
☝️
I added these definitions to the top of ~/unisoncode/scratch.u
mySquare : Nat -> Nat
mySquare x =
use Nat *
x * x
You can edit them there, then do `update` to replace the definitions currently in this branch.
This copies the pretty-printed definition ofmySquare
into your scratch file "above the fold." That is, it adds a line starting with---
and puts whatever was already in the file below this line. Unison ignores any file contents below the fold.
Let's editmySquare
and instead definemySquare x
(just for fun) as the sum of the firstx
odd numbers (here's anice geometric illustration of why this gives the same results):
use base
mySquare : Nat -> Nat
mySquare x =
sum (map (x -> x * 2 + 1) (range 0 x))
sum : [Nat] -> Nat
sum = foldLeft (+) 0
✅
I found and typechecked these definitions in ~/unisoncode/scratch.u. If you do an
''add'' or ''update'' , here's how your codebase would change:
⍟ These new definitions are ok to `add`:
sum : [Nat] -> Nat
⍟ These names already exist. You can `update` them to your new definition:
mySquare : Nat -> Nat
Adding an updated definition to the codebase
Notice the message says thatmySquare
is ok toupdate
.Let's try that:
tour/main> update
⍟ I've added these definitions:
sum : [Nat] -> Nat
⍟ I've updated these names to your new definition:
mySquare : Nat -> Nat
Only affected tests are rerun onupdate
If we rerun the tests, the tests won't be cached this time, since one of their dependencies has actually changed:
tour/main> test
✅
New test results:
◉ mySquare.tests.ex1 : Proved.
◉ mySquare.tests.prop1 : Passed 100 tests.
✅ 2 test(s) passing
Tip: Use view mySquare.tests.ex1 to view the source of a test.
Notice the message indicates that the tests weren't cached. If we dotest
again, we'll get the newly cached results.
The dependency tracking for determining whether a test needs rerunning is 100% accurate and is tracked at the level of individual definitions. You'll only rerun a test if one of the individual definitions it depends on has changed.
Publishing code and installing Unison libraries
Code is published to Unison's own code hosting solution,Unison Share,using thepush
command and libraries are installed via thepull
command. There's no separate tooling needed for managing dependencies or publishing code, and you'll never encounter dependency conflicts in Unison.