Skip to content

Latest commit

 

History

History
349 lines (280 loc) · 16.5 KB

File metadata and controls

349 lines (280 loc) · 16.5 KB

Contributing to Jolt

This is the contributor-side document: building from source, what lives where, the second Scheme backend, and the test gates. For using jolt, see README.md and jolt-lang.github.io.

Contents

Build from source

Running from source has no build step. The bootstrap seed (host/chez/seed/{prelude,image}.ss) is checked in, so a fresh clone runs immediately:

git clone --recurse-submodules https://github.com/jolt-lang/jolt.git
cd jolt
bin/jolt -e '(+ 1 2)'        # => 3

The --recurse-submodules matters: jolt vendors its regex engine, its Maven resolver, and its test suites as git submodules. In a checkout that's missing them (a plain git clone, or after pulling a commit that adds one), fetch them with:

git submodule update --init --recursive

GitHub's auto-generated "Source code (zip/tar.gz)" archives on the releases page do not contain submodules, so they can't run or build — clone the repo instead.

bin/jolt needs a threaded Chez Scheme 10.x on PATH as chez or chezscheme; set JOLT_CHEZ to point at a specific one. make uses a Chez on PATH at or above its pinned version as-is, and provisions its own 10.4.1 only when nothing qualifies. It exports JOLT_CHEZ so both halves of a build agree — running bin/jolt by hand against a 9.x picks up whatever primitive that release predates (variable flvector? is not bound) — and, when provisioning did run, JOLT_CC too, so the standalone binary links with the same GCC that built Chez instead of whatever cc happens to resolve to.

make build provisions Chez Scheme and a C compiler locally through Makes, then builds the standalone binary. An explicit CHEZ=/path/to/chez (or CHEZSCHEME=/path/to/scheme) is authoritative and bypasses local provisioning; release builders use this to retain their threaded Chez and platform toolchain. The conformance gate additionally uses Clojure on the JVM as an optional oracle, but running jolt does not.

The seed and re-minting

After changing a compiler source — the reader (host/chez/reader.ss), the analyzer/IR/backend (jolt-core/jolt/*.clj), or the clojure.core overlay (jolt-core/clojure/core/*.clj) — re-mint the seed:

make remint                   # iterates host/chez/bootstrap.ss to a byte-fixpoint

A change that is not followed by make remint silently does nothing: the checked-in seed still carries the old code, and rebuilding the binary alone does not help.

That trap extends past jolt-core/. ei-prelude-ns-files in host/chez/emit-image.ss also compiles seven stdlib/ namespaces into the seed — clojure.string, clojure.walk, clojure.template, clojure.edn, clojure.set, clojure.pprint, clojure.repl. At runtime a require of any of them no-ops, because the vars are already in the image, so an edit to one is invisible until the seed is re-minted. Every other stdlib/ namespace loads off the source roots and needs no re-mint.

Standalone jolt binary

make (or make build) installs the build dependencies locally through Makes and builds jolt itself into a single self-contained native binary. The runtime, compiler, jolt-core/stdlib source, and the Chez boots are baked in, so the result runs and builds jolt apps on a machine with neither Chez nor a C compiler.

make build                    # => target/release/jolt (optimize-level 3, compressed)
make install                  # => ~/.local/bin/jolt, or /usr/local/bin/jolt as root
make install PREFIX=/opt/jolt # explicitly override the installation prefix
make jolt-release             # force-rebuild the release binary
make jolt-debug               # => target/debug/jolt   (optimize-level 0, inspector + debug info)
make jolt                     # re-mint the seed first, then both

make jolt re-mints the seed so the embedded compiler image is current before linking; jolt-release/jolt-debug force their respective builds without re-minting. make clean removes build products; make distclean also removes the locally provisioned Makes toolchain.

Architecture

A small Chez runtime (host/chez/*.ss: value model, persistent collections, seqs, vars/namespaces, host interop) hosts a portable Clojure overlay split across two source roots by when they load:

  • jolt-core/ is baked into the seed — the compiler (jolt-core/jolt/: reader/analyzer/IR/backend, plus jolt.main/jolt.deps) and clojure.core in dependency-ordered tiers (jolt-core/clojure/core/NN-*.clj). Changing anything here means re-minting the seed.
  • stdlib/ loads lazily at runtime off the source roots — the rest of the standard library (clojure.string/set/walk/edn/pprint/…) plus the jolt.ffi host library. Editing most of these needs no re-mint; the seven listed in ei-prelude-ns-files are the exception (see below).

bin/jolt loads the checked-in seed and the spine, then compiles and evaluates on Chez (read → analyze → IR → emit → eval). host/chez/bootstrap.ss rebuilds that seed from source on pure Chez; the build is a self-hosting fixpoint (a rebuild reproduces the checked-in seed byte-for-byte).

host/gambit/ is the same overlay on a second Scheme — its own adapter, kernel, and cross-minted seed. See Scheme backends.

The per-module map is on the site: Module Map and Seed & Overlay Registry.

Scheme backends

Chez is the default target: every gate, library, and release runs there, and it is the only target with FFI, native compilation, program images, and standalone binaries. Gambit is a second, demo-grade target that also compiles to a single JavaScript file — the live REPL on the website is jolt evaluating in the browser.

Host-specific runtime code sits behind an adapter contract (host/scheme-adapter/CONTRACT.txt lists the names and capability tiers; TARGET-CONTRACT.md next to it is the porting document). A target implements a capability or degrades it honestly — an absent one raises rather than faking a result.

The Gambit targets need gambit-scheme (brew) and skip cleanly without it:

make gambitcheck              # adapter + shims on native gsi
make gambitkernel             # the booted kernel and natives (113 checks)
make gambiteval               # jolt source through the compiler, renders pinned to Chez
make gambitseed               # re-mint host/gambit/seed/ (runs on Chez, after a seed change)
make gambitweb                # => target/gambit/jolt-web.js, the browser bundle
make gambitweb PROFILE=repl   # a smaller bundle (see Build profiles below)
make gambitprofile            # gate: reduced profile runs, excluded features report

make gambitweb compiles the whole stack — kernel, seed, compiler, and a queue-polling REPL loop (host/gambit/repl-main.ss) — into one self-contained JavaScript file in about 30 seconds. The build is reproducible: the same sources produce a byte-identical bundle. Point it at a site checkout to refresh the live demo:

make gambitweb GAMBIT_WEB_OUT=../jolt-lang.github.io/resources/static/js/jolt-web.js

Some Gambit host files are generated from their Chez counterparts (for example records-gambit.ss from records.ss); run make gambitgen after editing the source, and make gambitgencheck gates the drift.

Build profiles

PROFILE selects how much of the language a build carries. host/gambit/profiles.ss lists the profiles and the optional feature groups they are built from; boot.ss remains the source of load order, and a group only names which of its files are optional.

make gambitweb PROFILE=repl    # clojure.core + compiler, no regex
make gambitweb PROFILE=full    # everything (the default)

Excluding a group does two things. Its files are left out, and every name it owned is bound to a raise that names the group — derived by scanning the excluded files for their definitions, so the error surface tracks the code instead of a hand-kept list. A dropped feature reports itself:

user=> (re-seq #"[a-z]+" "ab cd")
java.lang.UnsupportedOperationException: jolt-re-pattern is not in this build:
the regex feature group was excluded

A predicate over a type the build cannot hold answers false rather than raising — a value simply is not a regex — while anything that would produce or consume that type raises. make gambitprofile gates both halves: the reduced profile still runs the language, and an excluded feature names its group, including through indirection like clojure.string/split.

Measured cost of each group in the bundle (raw / gzipped, and gzipped is what a web server ships):

group cost without it
regex 2.4 MB / 0.4 MB no re-*, no #"..."
compiler 2.9 MB / 0.5 MB no eval, no REPL, no runtime macros
clojure.core 8.7 MB / 0.7 MB the kernel alone — an embedding, not a Clojure
kernel (floor) 19.4 MB / 2.1 MB the Gambit runtime plus jolt's kernel
full 31.0 MB / 3.3 MB

The floor dominates, so a profile trades features for the last third of the bundle: repl ships 27.7 MB / 3.1 MB against full's 32.6 MB / 3.5 MB. Adding a group is worth it when it is separable and measurable — a group worth kilobytes is churn.

The page defines joltQueue and joltOut before loading the bundle; a Scheme thread inside it polls the queue and hands results back, so page JavaScript never calls into Scheme.

Test

make test                     # the full gate
make corpus                   # conformance corpus vs the JVM-sourced spec
make unit                     # host-specific unit cases
make selfhost                 # bootstrap fixpoint (rebuild == checked-in seed)
make smoke                    # bin/jolt CLI smoke
make errorreport              # what a failing program PRINTS, pinned per case
make sci                      # load borkdude/sci's source through jolt (compat stress)
make ffi                      # the foreign-function interface, against C witnesses
make transient                # transient mutation + linear-time builds
make certify                  # JVM oracle (skips if clojure is absent)
make libconformance           # replay the downstream library suites vs recorded tallies

None of those measure throughput, and that is a real hole rather than an oversight to live with: bench/arrays once went 5.4x slower on a codegen change with all 88 ci targets and all 47 libraries still green — every answer was still correct. Run bench/run.sh after any change to the compiler passes, the emitter, or the runtime's hot paths, and read the table rather than the exit code; the suite reports, it does not judge.

NO_JVM=1 bench/run.sh          # the suite, optimized AOT binaries
bench/run.sh sorted-access     # one benchmark, to re-check a suspicious row
ci/bench-gate.sh A B           # two compilers head to head, ratios, exits nonzero

Suite noise is around 1.07x per benchmark on a quiet machine, and the FIRST benchmark of a run can be much further out than that, so a single suite run is not evidence on its own: re-measure anything that moved by running that benchmark alone, both before and after. A release runs ci/bench-gate.sh against the previous release automatically (.github/workflows/release.yml), and publish waits on it.

The conformance corpus (test/chez/corpus.edn) is a host-neutral language spec whose expected values are sourced from reference JVM Clojure. See test/conformance/SPEC.md.

Error reports are pinned the same way, by make errorreport: one directory per case under test/errors/, holding the program and the exact report jolt prints for it — message, position, ex-data, trace and exit status. The golden files record today's behaviour, bugs included, so that fixing one shows up as a diff a reviewer can read. After an intended change:

sh host/chez/error-report-check.sh generate    # then read the diff

Divergences from JVM Clojure are tracked, not tolerated silently: test/conformance/known-divergences.edn holds both the corpus rows whose value differs and the deliberate behavioural divergences that are not corpus rows. make certify fails on a new (unlisted) divergence and on a stale entry, so a behaviour change either matches the JVM or gets an entry explaining why it doesn't.

Error messages

An error message is read by someone who is stuck. It should let them answer three questions without opening the compiler: what is wrong, what was expected, and where. The rules below are what jolt's own error-reporting work settled on; each one exists because breaking it produced a real bug.

Match Clojure's wording where Clojure has one. First argument to def must be a Symbol, not a jolt paraphrase. People arrive here from Clojure and recognise these strings; a better-written message they have never seen is worse than the one they have. Where jolt has no counterpart, write a clear sentence.

(This is where jolt departs from jank's error style guide, which mandates complete sentences ending in a period. jank is the reference for its own errors and can spell them however it likes; jolt is not, and parity wins.)

Never let a host fault reach the user. java.lang.IndexOutOfBoundsException: index out of bounds as the compile error for (let [a 1 b] a) means a check is missing upstream, not that the message needs rewording. If a message names a Scheme primitive's failure, fix the check.

Say what was required, not only that something failed. An empty message — Unhandled exception (NullPointerException): and nothing — leaves out the one fact the reader needs. nil where a java.lang.String is required is the fix.

But an empty message is sometimes correct. The JVM's NoSuchElementException and its UnsupportedOperationException on a persistent collection both carry a null message. Parity beats a blanket rule: check the reference before populating one.

Raise a real throwable with a registered kind. A thrown string is not catchable by class and answers nil to ex-message, so a program cannot handle its own errors. Use analysis-error (analyzer), or rdr-error-kind / rdr-error-class (reader — the latter when the error matches a JVM class a program can catch, like NumberFormatException), and add the kind to test/conformance/error-kinds.ednmake errorkinds fails on a kind that is raised but unregistered, and on one registered but never raised.

Never render an arbitrary value into a message. It may be an infinite seq or a collection of any size. Name the type, or extract the offending text from the SOURCE line the way the caret does — that is bounded by the line.

State the fact in words, not geometry. A bare ^^^^ encodes which thing is wrong as a column offset; recovering it means counting characters. Give the diagnostic a :jolt.error/note so the caret is labelled. Reports are read by tools and language models as often as by people, and neither counts columns well.

Describe the state of the world, not the compiler's attempt. foo is not defined, not Failed to resolve foo. And state the constraint rather than blaming: A union is not passed by value, not You can't pass a union by value. The tone should be the same whether the cause was a typo or a misunderstanding.

One fact per sentence. Densely packed messages get skimmed and misread.

Checklist

  • Matches Clojure's wording, if Clojure has one for this.
  • Names what was expected, not just that something was wrong.
  • Not a raw host fault leaking through a missing check.
  • A real throwable, with a kind registered in error-kinds.edn.
  • No arbitrary value rendered into the text.
  • A :jolt.error/note where a caret needs labelling.
  • A case in test/errors/ if the shape of the report is new.

Documentation

User-facing documentation lives in the site repo (jolt-lang/jolt-lang.github.io), not in this one: markdown under resources/md/, every page registered in resources/docpages.edn. This repo carries only README.md, CHANGELOG.md, this file, and llms.txt.