jolt/.dirge/skills/jolt-dev/SKILL.md
Yogthos 33a5b7e7a4 feat: persistent vector (working) + HAMT hash map (wip)
PersistentVector: 17-form .clj source, fully working.
- 32-way branching trie with tail optimization
- pv-conj, pv-nth, pv-assoc, pv-pop, vector?, vector constructor
- instance? check works on deftype tables
- .-field accessor syntax for deftype fields

api.janet: :mutable? compile flag for opt-out
- Default: persistent data structures loaded
- Pass {:mutable? true} to use Janet native types

Supporting changes (evaluator/core):
- 17 new primitives: bit ops, array ops, unchecked math, hash, cond
- loop macro, zero?, dec/inc, defn multi-arity
- instance? for deftype tables (get val :jolt/deftype)
- defrecord builds maps at expansion time
- .-field field access in default function application path

PersistentHashMap: 24-form HAMT source loads OK.
- BitmapIndexedNode/PersistentHashMap deftypes
- mask, bitpos, bit-count, index helpers
- phm-assoc/phm-get/phm-without/phm-contains? stubs
- bmn-assoc insert path structured, bitmap propagation wip
2026-06-02 11:51:39 -04:00

13 KiB

jolt-dev

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:

  1. Add the name to special-symbol? in src/jolt/evaluator.janet
  2. Add a match arm in eval-list (the match on name)
  3. 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 &env binding: (put new-bindings "&env" @{}) — table, not struct (nil-safe)
  • Uses parse-arg-names for & rest arg handling

defmulti / defmethod

  • defmulti stores the methods table on the var via (put v :jolt/methods methods), NOT on the function
  • Janet put on a function value fails silently
  • defmethod retrieves methods via (get mm-var :jolt/methods) using resolve-var to 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-method to work before defmulti print-method is defined

deftype

  • Handles ^:meta on type name via with-meta pattern
  • Fields vector handles ^:meta annotations and ^Type hints — extracts actual symbol name
  • Produces a table with :jolt/deftype "ns.TypeName" and keyword-keyed fields

Janet Gotchas

  • var declares mutable locals that can be set later; def/let are immutable
  • let cannot bind to nil — 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
  • match is Janet's pattern matching — no case or cond needed 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., &env binding @{} for macro bodies)
  • match with string patterns returns nil (not error) when no pattern matches. Used in eval-list to handle non-symbol heads cleanly — keyword heads like :ns fall through to default function application
  • break in while does 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/type method — 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 nillast works 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's set doesn't support destructuring. Use (tuple 0) / (tuple 1) or explicit individual assignments
  • (get table key) does NOT walk prototypes set by table/setproto. Use a custom walker: (var t bindings) (while t (when (in t key) (set result (in t key)) (break)) (set t (table/getproto t))). (in table key) DOES walk prototypes.
  • return from loop inside fn body doesn't work — use mutable var result + break pattern instead.
  • Bit-shift functions: blshift (left), brshift (right), brushift (unsigned right). NOT lshift/rshift/urshift. brushift on negative numbers errors.
  • (hash x) built-in works on strings, keywords, numbers, tuples, structs, tables. Used directly as core hash binding.

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 :cljs branch), 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).

fn* and defmacro namespace capture

fn* and defmacro must capture defining namespace at definition time and restore it during body evaluation:

(let [defining-ns (ctx-current-ns ctx)]
  ...closure body...
  (def saved-ns (ctx-current-ns ctx))
  (ctx-set-current-ns ctx defining-ns)
  ...eval body...
  (ctx-set-current-ns ctx saved-ns))

Applies to single-arity, multi-arity fn*, and defmacro. Without this, closures evaluate in whatever ns happens to be current at call time.

Persistent data structures

  • ClojureScript defines persistent vector as deftype in cljs/core.cljc: PersistentVector (cnt, shift, root, tail, _meta), VectorNode (arr). 32-way branching trie with tail optimization.
  • Mutable arrays via object-array, int-array, aset, aget, aclone, alength, to-array. Bit ops: bit-and/or/xor/not, bit-shift-left/right, unsigned-bit-shift-right.
  • int coercion via math/trunc. unchecked-inc/dec/add/subtract for fast path.
  • .clj source files in src/jolt/clojure/lang/. Loaded by Jolt reader/evaluator at init.

instance? and set! for deftype

  • instance? checks :jolt/deftype tag. Matches by suffix: (instance? PersistentVector x) → check if (x :jolt/deftype) ends with "PersistentVector".
  • set! handles (set! (. obj -field) val) for deftype field mutation. Strips leading -, converts to keyword, (put obj field-key val).

and / or macros

(and x y) → (let* [and__x x] (if and__x (and y) and__x))
(or x y)  → (let* [or__x x] (if or__x or__x (or y)))

Registered as macros in core-macro-names.

defrecord macro

Builds key-value pairs at macro expansion time using array-map:

(var kvs @[])
(each f fields-vec
  (array/push kvs (keyword (f :name)))
  (array/push kvs f))
(def map-expr @[{:jolt/type :symbol :ns nil :name "array-map"} ;kvs])

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-next uses inner parse-next-loop that recurses on skip
  • parse-string recurses 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 by read-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-names returns @{"when" true "defn" true "declare" true "defprotocol" true "extend-type" true "extend-protocol" true "extend" true "reify" true "fn" true "proxy" true "definterface" true "comment" true "defn-" true} — a table
  • init-core! calls (get (core-macro-names) name) to check, then (put v :macro true)
  • Order matters: macro functions must be defined BEFORE core-bindings map references them

Core stubs for sci bootstrap

  • core-derive, core-isa?, core-ancestors, core-descendants — minimal hierarchy
  • core-Object, core-Thread, core-ThreadLocal, core-IllegalStateException — JVM class stubs
  • core-volatile!, core-vswap!, core-vreset! — volatile (atom-like table with :val key)
  • core-defprotocol emits (do (def name @{}) (def method fn) ...) — macro, returns do form
  • core-extend-type, core-extend-protocol, core-extend, core-reify, core-satisfies?, core-extends?, core-implements?, core-type->str — protocol stubs

Namespace handling

  • ns form handles ^:meta on ns name via with-meta pattern
  • def form also handles ^:meta on def name (extracts name-sym from (with-meta Name meta))
  • require clause in ns wraps each spec in (when s ...) for nil-safety
  • resolve-var falls back to checking clojure.core namespace if var not found in current ns

Bootstrap loading order

sci.impl.macros      (4/4 ok)
sci.impl.protocols   (15/17 ok)
sci.impl.utils       (39/47 ok)
sci.impl.types       (22/27 ok)
sci.impl.unrestrict  (2/2 ok)
sci.impl.vars        (28/28 ok — comment block parsed as skip)
sci.lang             (10/10 ok — IVar via class-name resolve)
sci.ctx-store        (6/6 ok)
sci.impl.namespaces  (93/98 ok — parse crash at unmatched brace)
sci.core             (60/69 ok — namespaces/*1/*2/*3/*e fail)

All .cljc files. #?(:clj ...) resolved at read time. #?(:cljs ...) returns nil.

parse-arg-names

  • Handles & rest args 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