- Reorder load sequence: types→unrestrict→vars→lang→utils→namespaces→core
- Add if-let/when-let/if-some/when-some/let macros and register in core-macro-names
- Add stubs: resolve, update, copy-core-var, copy-var, macrofy, new-var, avoid-method-too-large
- Add dynamic vars: *1, *2, *3, *e, *assert with nil-sentinel
- Fix core-update: use put instead of Janet's update (struct-incompatible)
- Change *clojure-version* from struct to table (mutable for update)
- Fix core-avoid-method-too-large: return @{} not nil
- Fix init-core! nil-sentinel unwrapping
- Fix let* destructuring for {:keys [...]} and sequential patterns
- Fix fn*/defmacro: capture defining ns for symbol resolution in closures
14 KiB
| description |
|---|
| Jolt development workflow — build, test, special form patterns, Janet gotchas |
Jolt Development
Build & Test
cd /Users/yogthos/src/jolt
jpm build # produces build/jolt
jpm test # runs all tests
janet test/foo.janet # run a single test file from project root
Special Form Checklist
To add a new special form to the evaluator:
- Add the name to
special-symbol?insrc/jolt/evaluator.janet - Add a match arm in
eval-list(the match onname) - Add tests in
test/evaluator-test.janet
The match arm receives ctx, bindings, and form (the full list). Use (in form 1) for first arg, etc.
Non-symbol heads (keywords, etc.): eval-list first checks (and (struct? first-form) (= :symbol (...))) before extracting name. If not a symbol, falls through to default function application. This means (:ns &env) works because the head :ns is a keyword, not a symbol, so it's evaluated and called as a lookup.
Current special forms (22):
quote, syntax-quote, unquote, unquote-splicing, do, if, def, defmacro, fn*, let*, loop*, recur, throw, try, set!, var, locking, instance?, defmulti, defmethod, deftype, new, .
defmacro details
- Supports optional docstring:
(defmacro name [args] body)or(defmacro name "doc" [args] body) - Implementation:
(tuple/slice form 2)→ check if first is string → adjust args-form and body start - Implicit
&envbinding:(put new-bindings "&env" @{})— table, not struct (nil-safe) - Uses
parse-arg-namesfor& restarg handling
defmulti / defmethod
defmultistores the methods table on the var via(put v :jolt/methods methods), NOT on the function- Janet
puton a function value fails silently defmethodretrieves methods via(get mm-var :jolt/methods)usingresolve-varto get the var first
set!
- If var doesn't exist, auto-creates it (
ns-intern) rather than erroring - Needed for sci's
(set! *warn-on-reflection* true)
defmethod (auto-create)
- If the multimethod var doesn't exist yet, auto-creates it with a dummy fn and empty methods table
- This allows sci's
defmethod print-methodto work beforedefmulti print-methodis defined
deftype
- Handles
^:metaon type name viawith-metapattern - Fields vector handles
^:metaannotations and^Typehints — extracts actual symbol name - Produces a table with
:jolt/deftype "ns.TypeName"and keyword-keyed fields
Janet Gotchas
vardeclares mutable locals that can besetlater;def/letare immutableletcannot bind tonil— use(var x nil)instead of(let [x nil] ...)(get table key)needs 2 args minimum — for single-arg checks use(table :key)- Functions are not tables —
(put fn :prop val)fails. Stash properties on vars matchis Janet's pattern matching — nocaseorcondneeded for simple dispatch- Janet structs silently drop entries with nil values:
(struct ;[:x nil :y 1])→{:y 1}. Use@{}mutable tables when nil-valued entries are needed (e.g.,&envbinding@{}for macro bodies) - Janet
(put table key nil)silently drops the key — even on mutable@{}tables. Usebind-puthelper (stores:jolt/nilsentinel) for binding tables;resolve-symunwraps:jolt/nilback tonil. Allputcalls on bindings infn*,let*,loop*, macro bodies,deftypereify MUST usebind-put resolve-symreturns:jolt/not-foundsentinel to distinguish nil local bindings from absent ones; falls back toclojure.corefor unqualified symbolsmatchwith string patterns returns nil (not error) when no pattern matches. Used ineval-listto handle non-symbol heads cleanly — keyword heads like:nsfall through to default function applicationbreakinwhiledoes NOT return a value in Janet. Use(var done nil)+(set done val)+ check pattern instead- Janet
#{}set literals can cause parse issues in some contexts — use@[]as fallback (first struct)calls:jolt/typemethod — use(get struct :key)instead of positional access- Janet
(struct ;[:x nil])silently drops the nil entry — the map becomes{}. Use@{}tables for nil-safe entries (length struct)counts key-value pairs, not keys. Use(length (keys struct))for key count(last string)returns nil —lastworks only on indexed types (tuple, array). For strings use(s (- (length s) 1))or(string/slice s (- (length s) 1))(set [a b] tuple)doesn't work — Janet'ssetdoesn't support destructuring. Use(tuple 0)/(tuple 1)or explicit individual assignments(string :keyword)returns"keyword"(no colon) — use this for keyword→string conversion. Avoid(name :keyword)in Jolt eval context;nameis a Janet built-in but may shadow or be unavailable(get struct :key)works,(in struct :key)fails —inis for indexed types only (tuple, array, string, buffer). Usegetfor structs/tables- Janet
trytakes exactly 2 args:(try body ([err] handler)). Multi-expression body must wrap indo:(try (do expr1 true) ([err] handler))
unwrap-meta-name helper
Recursively unwraps (with-meta sym meta) forms to extract the underlying symbol. Used in def, ns, deftype, defmethod to handle metadata-wrapped names:
(defn- unwrap-meta-name [form]
(if (and (array? form) (> (length form) 0)
(struct? (in form 0))
(= :symbol ((in form 0) :jolt/type))
(= "with-meta" ((in form 0) :name)))
(unwrap-meta-name (in form 1))
form))
Reader map k/v handling
The map reader must handle three special value types in both key and value positions:
:jolt/skip— discarded form (comment,#_, nil#?(:cljs ...)): skip the K/V pair entirely:jolt/splice—#?@splicing: concat items into the kvs array. If splice has 0 items (nil:cljsbranch), don't push the key
#?(:cljs X) returns nil → :jolt/skip
The non-splicing #? reader now returns {:jolt/type :jolt/skip} for nil results (e.g., #?(:cljs X) on CLJ). This prevents orphaned nil keys/values in maps and lists. parse-next and parse-string skip past skip markers to return the next real form.
deftype →TypeName constructor
deftype now interns both the type name and ->TypeName arrow constructor (Clojure convention). The dot-suffix constructor check uses (sym-name (- (length sym-name) 1)) instead of broken (last sym-name).
Comment Handling
Comments ; in read-form return {:jolt/type :jolt/skip} sentinel:
(= c 59) # ;
[{:jolt/type :jolt/skip} line-end])
parse-next and parse-string both skip over :jolt/skip results:
parse-nextuses innerparse-next-loopthat recurses on skipparse-stringrecurses on skip to return next non-comment form
read-map checks both key AND value positions for :jolt/skip to skip #_-discarded entries.
Closing delimiters ), ], } in read-form produce explicit errors:
(= c 41) (error (string "Unmatched closing paren at " pos))
This prevents them from falling through to read-symbol which gave "Unrecognized character".
unwrap-meta-name Utility
Recursively unwraps (with-meta sym meta) forms to extract the underlying symbol:
(defn- unwrap-meta-name [form]
(if (and (array? form) (> (length form) 0)
(struct? (in form 0))
(= :symbol ((in form 0) :jolt/type))
(= "with-meta" ((in form 0) :name)))
(unwrap-meta-name (in form 1))
form))
Used in: def, ns (ns name), deftype (type name + field names), defmethod (arg names).
Replaced duplicated with-meta unwrapping code in each of these forms.
Bootstrap Patterns
Class-name resolution in resolve-sym
When a simple symbol (unqualified) isn't found in current ns or clojure.core, checks for dotted class-name pattern:
Foo.Bar.Baz → finds last dot → ns "Foo.Bar", name "Baz" → tries ns-resolve.
This resolves symbols like IVar that are interned in sci.impl.vars but referred to unqualified from sci.lang.
Reader conditionals
#?(:clj expr :cljs expr2)— resolved at read time byread-reader-conditional#?@(:clj expr :cljs expr2)— splicing variant, wraps resolved items in{:jolt/type :jolt/splice :items ...}- List/vector/set readers check for splice and flatten items
#_— discard reader macro, reads next form and returns it as position only
Core macros (in core.janet)
core-macro-namesreturns@{"when" true "when-not" true "if-let" true "when-let" true "if-some" true "when-some" true "defn" true "defn-" true "declare" true "fn" true "let" true "defprotocol" true "extend-type" true "extend-protocol" true "extend" true "reify" true "proxy" true "definterface" true "comment" true}— a tableinit-core!calls(get (core-macro-names) name)to check, then(put v :macro true)- Order matters: macro functions must be defined BEFORE
core-bindingsmap references them
Core stubs for sci bootstrap
core-derive,core-isa?,core-ancestors,core-descendants— minimal hierarchycore-Object,core-Thread,core-ThreadLocal,core-IllegalStateException— JVM class stubscore-volatile!,core-vswap!,core-vreset!— volatile (atom-like table with :val key)core-defprotocolemits(do (def name @{}) (def method fn) ...)— macro, returns do formcore-extend-type,core-extend-protocol,core-extend,core-reify,core-satisfies?,core-extends?,core-implements?,core-type->str— protocol stubscore-copy-core-var,core-copy-var,core-macrofy,core-new-var,core-avoid-method-too-large— sci.impl.copy-vars stubs (needed by namespaces)core-resolve— stub returning nil (sci symbol resolution check)core-update— delegates to Janet'supdatewith applycore-prefer-method— multimethod preference ordering stub
:jolt/nil-sentinel pattern for nil bindings
Janet table literals drop entries with nil values: @{"x" nil} → {}. For core bindings that need nil root values (e.g., *1, *2, *3, *e), use the sentinel:
"*1" :jolt/nil-sentinel
"*2" :jolt/nil-sentinel
init-core! unwraps sentinels back to nil:
(def v (ns-intern ns name (if (= fn :jolt/nil-sentinel) nil fn)))
fn* namespace capture
Functions close over their defining namespace so symbols resolve correctly regardless of caller's current-ns:
"fn*" (let [args-form ...
defining-ns (ctx-current-ns ctx)] ; capture at definition time
(set self (fn [& fn-args]
(def saved-ns (ctx-current-ns ctx))
(ctx-set-current-ns ctx defining-ns) ; restore defining ns
(var result nil)
(each body-form body
(set result (eval-form ctx fn-bindings body-form)))
(ctx-set-current-ns ctx saved-ns) ; restore caller's ns
result)))
Same pattern in defmacro bodies.
let* destructuring
Supports :keys destructuring with both symbol keys and keyword keys:
(if (struct? pat)
(let [keys-vec (get pat :keys)]
(if (and keys-vec (indexed? keys-vec))
(each k keys-vec
(def kname (if (keyword? k) (string k) (k :name))) ; keyword→string, symbol→name
(bind-put new-bindings kname (get val (keyword kname))))
(bind-put new-bindings (pat :name) val))) ; plain symbol binding
...
Also supports sequential destructuring of vector patterns.
New core macros
core-if-let— expands(if-let [x expr] then else)→(let* [x expr] (if x then else))core-when-let— expands to(let* [x expr] (when x body))core-if-some— expands to(let* [x expr] (if (some? x) then else))core-when-some— expands to(let* [x expr] (when (some? x) body))core-let— expands(let [bindings] body)→(let* [bindings] body)(macro, likefn→fn*)
Namespace handling
nsform handles^:metaon ns name viawith-metapatterndefform also handles^:metaon def name (extracts name-sym from(with-meta Name meta))requireclause innswraps each spec in(when s ...)for nil-safetyresolve-varfalls back to checking clojure.core namespace if var not found in current ns
Bootstrap loading order (corrected)
sci.impl.macros (4/4 ok)
sci.impl.protocols (15/15 ok)
sci.impl.types (22/22 ok)
sci.impl.unrestrict (2/2 ok)
sci.impl.vars (28/28 ok)
sci.lang (10/10 ok)
sci.impl.utils (45/46 ok — 1 fail on dynamic-var internals)
sci.impl.namespaces (124/127 ok — 3 remaining: major destructure, clojure-version, iterable)
sci.core (64/65 ok — 1 remaining form)
All .cljc files. #?(:clj ...) resolved at read time. #?(:cljs ...) returns nil.
Critical load order: macros → protocols → types → unrestrict → vars → lang → utils → namespaces → core. utils must load after lang (needs lang/->Namespace, vars/unqualify-symbol). namespaces needs utils (for clojure-core-ns, dynamic-var). core needs namespaces (for *1, *2, *3, *e, resolve).
parse-arg-names
- Handles
& restargs AND nested destructuring vectors - When an arg is a vector (not a symbol), recurses to extract nested symbol names
Project Structure
src/jolt/
types.janet — Var, Namespace, Context, symbol helpers
reader.janet — recursive descent parser for Clojure syntax
evaluator.janet — tree-walking interpreter (eval-form, eval-list, syntax-quote*)
core.janet — 95+ clojure.core functions (map, filter, reduce, etc.)
api.janet — public API: init, eval-string, eval-string*
main.janet — REPL entry point with (defn main [&])
test/
evaluator-test.janet — special form tests (22 forms tested)
reader-test.janet — parser tests (includes #?, #?@, #_)
core-test.janet — core library tests
macro-test.janet — syntax-quote and macro tests
namespace-test.janet — ns, require, in-ns tests
types-test.janet — Var and Namespace tests
api-test.janet — public API tests
bootstrap-test.janet — loads sci.impl.macros (deftime, usetime, ?)
test-load-sci.janet — loads all sci files, counts ok/fail
test-eval.janet — end-to-end sci.core/eval-string test