essenceDocumentation
essenceDocs
on this pageEvery file opens with one wrapper

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 format writes tabs and lays a file out to fit 80 columns, and that is the whole style. Run essence format --check '*.es' in CI and essence format src/*.es before 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 check is the fast gate. It stops after validation and writes nothing, so it is the form meant for editors, pre-commit hooks and CI. --json turns it into a diagnostic list another tool can read. It checks the tests { … } 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

esc