Project Layout
There is no manifest and no project file. A program is an entry file and whatever it imports, and what comes out of the compiler runs on its own.
essence has no project format. There is nothing to initialise, no manifest to
fill in and no lockfile of its own — a program is a .es file, and essence is
pointed at it directly.
A program can still be several files. One file is one module, and an import
block above the implementation names what it takes from another. What there is
no format for is the project
around them: the imports are the file list, and the compiler needs nothing else.
Every file opens with one wrapper
implementation {
§ everything the program is
}
implementation { … } is the form a program takes, and the only thing that may
stand outside it is a module’s import and export block. The other form,
declarations { … }, exists for the standard library: it is what allows a
namespace body to hold method signatures with no body, which is how a
declaration says “the runtime implements this”. The compiler refuses
declarations outside the standard library’s directory, so implementation is
the one you will write.
Where the output goes
By default, next to the source:
$ essence build hello.es
✔ hello.es → hello.js
--out sends it somewhere else. With one input it is a file; with several it
is a directory, and each source keeps its own name. Missing parent directories
are created.
$ essence build src/*.es -o dist/
✔ 2 files compiled
✔ src/main.es → dist/main.js 6.8 kB 121 ms
✔ src/other.es → dist/other.js 6.8 kB 122 ms
13.6 kB emitted across 2 files in 143 ms · 2 workers
Each output is a self-contained ES module. The parts of the essence runtime the program actually reaches are bundled into it, along with every module it imports — nothing is imported at runtime, and there is no runtime package to install alongside it — so it can be executed by Bun or Node, or loaded in a browser.
So what does a project look like
Whatever you want it to. A directory of .es files and a shell command is a
complete setup:
my-app/
src/
main.es ← imports report.es
report.es
dist/ ← whatever essence last wrote
essence check src/*.es # before committing
essence test # the tests the sources themselves write
essence build src/main.es -o dist/main.js # before shipping
essence watch src/main.es # while working
One entry is enough to build: main.es pulls in everything it imports, and
dist/main.js holds all of it with no import statement left. Naming several
files instead asks for several bundles, one per file, which is what you want for
several entry points and not for one program’s parts.
essence check src/*.es is still the form for a check, because a module nothing
imports yet is a module the entry cannot reach — and it is the file most likely
to be the one you are working on.
Several files given at once are compiled in parallel, one worker per core
up to eight, and --jobs 1 puts everything back on the main thread — which is
what you want when a compiler crash gives you a stack trace to read.
What a project may configure
Tests are written in the language: a file ends in a tests { … } section beside
its implementation, or is a Foo.tests.es of nothing but imports and tests.
essence test finds them under the working directory, compiles them and runs
them; every other command drops the sections before anything is enriched, so
they cost a shipped program nothing.
A test may carry tags, and a project usually wants some of them left out of an
everyday run. That is the one setting there is, and it lives in the nearest
package.json — the file a project already has, under a key a project file can
adopt whole later:
{
"essence": {
"test": {
"skipTags": ["slow"]
}
}
}
essence test --skip-tag slow says the same thing for one run, and
essence test --tag slow runs exactly the tests a default like that leaves out
— which is how a nightly job runs what a working day skips.
The same key takes "contracts", which says whether a run also tests what the
project’s own declarations promise — see
the properties a declaration already states.
essence test --contracts says it for one run, and the two are a union: the flag
asks about a project that never configured them, and the setting asks for them
every time.
The same key takes "exclude", a list of directories the search stays out of,
written relative to the package.json that names them — a corpus of
deliberately broken sources, an example a book quotes. It narrows the search and
nothing else: a file named on the command line was asked about by name, and is
still compiled and still reported.
While you are writing them, essence test --watch stays up and re-runs only the
tests a save reached — the entries whose module graph holds the file that
changed — clearing the screen and reprinting the whole picture each time. A
statement whose line ends in §? answers with what it held; it is an ordinary
comment to every other command.
essence test --coverage reports what the run reached, in a table beside the
results: lines and branches as percentages, match arms as taken out of total,
and then the part a line-counting tool can not say — every arm no value took
and every Case of a choice no test ever built, each named by the Method it
was written in.
File Lines Branches Cases Not taken
Season.es 43% – 0/6 Fixture::toString › case #Played
Standings.es 97% 95% 9/9 Standing::recentForm › else
Table.es 96% 83% – signOf › if
--coverage-report lcov writes the tracefile a coverage viewer reads, and
--coverage-report json the Compiler’s own vocabulary — the arms, the guarded
branches and the scope each point stands in, none of which lcov can spell. Both
write into coverage/, or into --coverage-out <directory>.
Properties
A test written for any (…) declares typed Parameters, and the runner generates
a value of each Type once per case — a hundred cases by default. This is where
the type system pays out: the generator is derived from the Type, structurally.
A Record is built member by member, a Choice is built Case by Case, a List from
its item Type, and a checked refinement is honoured, so a NonEmptyList is
never empty and a NonZeroInteger is never zero:
test "add commutes" for any (a: Integer, b: Integer) {
expect a::add(b)::is(b::add(a))
}
test "ranking keeps every team" for any (standings: NonEmptyList<Standing>) {
constant ranked = Standings.ranked(standings)
expect ranked::length()::is(standings::length())
}
A failing case is shrunk: the runner keeps trying smaller values that still
fail, so what a report shows is the smallest counterexample it could reach
rather than the wall of digits the source happened to draw. Beside it stands the
seed the values came from, and essence test --seed <hex> draws them again —
each test folds its own identity into the run’s seed, so replaying one test with
--filter still draws exactly what it drew. --cases <count> runs another
number of them.
[test-failed]
Error: 'recording a result never lowers points' failed
│ Help: Run it again: essence test --seed 41c37ea3 -f "never lowers points"
│
│ Note: after 37 cases, shrunk to: standing = { …, points = 0 } scored = 0 conceded = 1
The value a shrink ended at is also written down, in
__counterexamples__/<File>.es.json beside the source — a sibling of the
snapshots, kept under the same identity everything durable is keyed by, and
meant to be committed. Every later run replays what that file holds before it
draws a single case, so a bug the search found once is caught by the run after
it whether or not a fresh hundred cases would turn it up again:
[test-failed]
Error: 'recording a result never lowers points' failed
│ Note: failed on a stored counterexample (1 re-run): standing = { …, points = 0 } scored = 0 conceded = 1
A replay draws nothing and is counted apart from the cases — a test that
re-ran one and then passed reads (100 cases · 1 replayed) — so --seed goes
on reproducing the run it names however much the corpus has grown since. A
stored value is kept once the code is fixed, because what it is worth is that it
goes on being asked; one the Types no longer fit is dropped by the run that met
it. A test keeps the ten newest, and a run narrowed by --filter or a tag
leaves what it did not visit alone.
A Type whose values carry an invariant no structure can know about generates
itself instead. A Namespace conforming to Generatable replaces the derived
generator for the Type it targets:
namespace Team for Team is Generatable {
static generate(from source: Randomness) -> Team {
<- { name = source::pick(from ["Lions", "Tigers", "Bears", "Wolves"]) }
}
}
A Randomness is the one value in the language that changes: every Method
answers a value and advances the source, so asking for a name and then for a
score never answers the same draw twice.
A generator written this way says how to build a value and nothing about what one is made of, which is the whole of what writing one down would need — so a property test with such a Parameter keeps no counterexamples, and neither shrinks nor replays them.
The properties a declaration already states
A checked refinement is a property, and a Method that writes one in its return
Type has already stated the property its answer holds. essence test --contracts
runs it: the receiver and every Argument are generated from the declared Types,
the Method is called, and the answer is expected to hold each conjunct the
return Type promises.
namespace Standings for NonEmptyList<Standing> {
§ Every goal below runs this with a generated receiver and expects the
§ answer to be above zero, because that is what the Type says.
leaderPoints() -> PositiveInteger {
<- @::highestItem(on (standing) { <- standing.points }).points
}
}
A goal that throws fails whatever the return Type says, because a signature
is a promise to answer over the domain it names — so a Method with no refinement
at all still gets a goal, and totality is what it checks. The goals are reported
under a contracts suite beside the tests the project wrote, named as the Method
is spelled, and they are ordinary property tests in every other respect: the same
seeds, the same shrinking, the same stored counterexamples.
Nothing is synthesized for a generic Method — a value drawn for one instantiation
proves nothing about the rest — nor for one whose Parameters nothing can build a
value of. Both are named once per Namespace as an ungeneratable-contract
remark, which is a note about what a run is covering rather than a mistake
anybody made.
A project that wants them every run says so in the same package.json key:
{
"essence": {
"test": {
"contracts": true
}
}
}
Tables, snapshots and examples
A test written across a List of rows runs once per row, with the row bound to
the name after it and the test’s own name interpolating whatever the row holds
— so the report says which row failed:
test "{scored}–{conceded}" across [
{ scored = 2, conceded = 1 },
{ scored = 1, conceded = 1 },
] ({ scored, conceded }: Scoreline) {
expect scored::isGreaterThanOrEqualTo(conceded)
}
The rows are written where the test is, because each of them is a test in its own right and carries its row number in the identity everything durable is keyed by.
matches snapshot compares a value against one a run recorded. Written bare, it
is recorded inline: the first run writes the value back into the source
through the formatter, and the diff is where it is reviewed. Written
from "name", it is kept in __snapshots__/<File>.es.snap beside the source,
which is where output too large to read inline belongs:
test "renders the table" {
expect Table.render(table) matches snapshot from "after-round-7"
}
A snapshot nothing has recorded is written and the test passes; one that differs
is a failure with the difference as a note, and essence test --update records
it instead. In an editor, the “Accept snapshot” lens above the test does the
same for that test alone.
An @example block in a §§ documentation comment is a test as well. It is
compiled in the file’s own scope and reported under a synthetic examples
suite, named after the Method or Function it documents — so an example that
drifts from the code fails a run rather than quietly lying in hover text:
§§ The points a Standing has won per game played.
§§
§§ @example
§§ constant standing = Standings.blank(of lions)::record(scored 2, conceded 0)
§§
§§ expect standing::pointsPerGame()::is(3/1)
§§
§§ @returns — the points divided by the games played, or 0/1 before any game.
pointsPerGame() -> Rational {
Benchmarks
A benchmark is a test whose body is timed rather than judged. It takes the
same name, the same Modifiers and the same across rows a test takes — what
differs is what running it means:
benchmark "ranks a full division" {
expect Standings.ranked(division)::length()::is(20)
}
Measuring a body takes hundreds of runs of it, so a benchmark is left out of an
ordinary run and counted as deselected — the tree says only with --bench where
it would have run. essence test --bench measures them as well as running
the tests, because a measurement of a body that is wrong is a number about
nothing: a benchmark whose assertions do not hold is reported as the failure it
is and never timed.
The body is run in batches until one takes long enough for the clock to have
something to say, several batches are taken, and the middle one answers. What
comes out is the time of a single run, and the first measurement is recorded as
a baseline in __benchmarks__/<File>.es.bench beside the source:
benchmark "ranks a full division"
1204833 ns
Later runs compare against it. A quarter slower fails — a benchmark says
what a body may cost the way an assertion says what it may answer — and the
report carries both times and the one command that accepts the new one. A fifth
faster passes and says so, but does not move the baseline on its own: a fast
machine would otherwise ratchet the number down for everybody.
essence test --bench --update records what this run measured. A benchmark
written across rows keeps a baseline per row, numbered the way a table test’s
snapshot is.
for any is refused here. A measurement is comparable only where every run does
the same work, and a generated value changes the work every case.
Mutation testing
Coverage says a line ran. Mutation says a bug there would be caught.
essence test --mutate asks the second question: it changes the code on
purpose, one deliberate lie at a time, and runs the tests that reach the line
to see whether any of them notices.
essence test --mutate # every module the walk finds
essence test --mutate src/Standings.es # one file's own lies
41 mutants · 36 killed · 3 survived · 2 on lines no test reaches · 92% caught
✗ Standings.es:31 swap ::isGreaterThan for ::isGreaterThanOrEqualTo — every test still passes
if scored::isGreaterThan(conceded) {
reached by 4 tests
The lies are the compiler’s, told on the typed program rather than on the text,
so every one of them is a program that still typechecks: a comparison rotated a
single step, is against isNot, addition against subtraction, a Boolean
literal flipped, an if turned inside out by swapping its two bodies, an
Integer literal nudged up, down or to zero, and a Case construction swapped for
a sibling Case of its own Choice. That last one is the operator an
exhaustive language makes possible: the Cases are a closed set, so “no test
notices this being a #Draw” is a complete statement rather than a guess.
A mutant is judged only by the tests that reach it. The run compiles once with the coverage counters in, runs the tests once with each of them saying which lines it touched, and then runs each mutant against that line’s own tests alone — so the cost is a compile and a handful of tests, not a compile and the whole suite. A site no test reaches is reported as its own count and never compiled at all: it is the coverage report’s finding wearing mutation’s hat.
A mutant that crashes — one whose bundle will not load, or which throws as it runs — counts as killed: a program that comes apart is a program the world notices. A mutant whose run never comes back is stopped and counted as hung, which counts as caught too, because a loop that no longer ends is a failure a reader would see as surely as a red test. The wait before a run is given up on is ten times what the same tests took when they were green, and never less than five seconds. Only a mutant that would not compile is left out of the score altogether; that is the walker’s own shortfall rather than anything the tests did.
The whole run draws from one seed, minted once and handed to the baseline and to every mutant alike, so a property test in a covering set asks the mutant exactly the question it asked the code. Two runs at one seed are the same report.
The baseline is the run this project’s own essence test is: the same
filters, and the same contract goals where the project asks for them, so a
generated goal reaches sites and kills mutants exactly as a written test does.
It has to be green, and it has to be unfocused — a focus silences most of the
suite, and a mutation run reads what ran to learn what reaches where, so a
focused baseline would report every site the silenced tests cover as one no test
reaches. A focused run is refused with exit 2 before a mutant is compiled, and
--without-optimisation instrument-coverage is refused outright: without the
counters there is no table to read.
--mutation-limit caps how many mutants are compiled — a mutant costs a compile
and a run, so a large project is worth narrowing — and the sites are taken in
file order, so the same limit answers about the same mutants twice. It caps what
is judged and nothing else: a site no test reaches costs neither a compile
nor a run, and is reported whatever the limit. The report says where the limit
stopped and how many sites it left alone, and the mutation-end tally still
counts every site the walker found, so a consumer subtracts to get the rest.
A mutation score is information, so the run exits 0 whatever it found.
--strict is what a job that holds a project to its score asks for: a survivor
exits 1, the way a failing test does. --json writes one mutant event per
line and a mutation-end tally, exactly as --json does for a test run.
Conventions worth keeping
None of these are enforced. They are what the repository itself does.
- Tabs, and 80 columns. There is nothing to configure:
essence formatwrites tabs and lays a file out to fit 80 columns, and that is the whole style. Runessence format --check '*.es'in CI andessence format src/*.esbefore committing. - Formatting is never something a build does to your sources. A build does not touch the files it reads. Formatting is its own command, and its own decision.
essence checkis the fast gate. It stops after validation and writes nothing, so it is the form meant for editors, pre-commit hooks and CI.--jsonturns it into a diagnostic list another tool can read. It checks thetests { … }block as well, which a build never does — a build drops the section, and a gate that said nothing about it would pass a file the next step cannot compile.
Next
- Installation — every
essencecommand, and the four ways to install the toolchain. - Your first program — four steps, about five minutes.