Skip to content

Fe Lisp — Language Reference

The Fe VM is the cartridge authoring substrate for KN-86 (ADR-0001, ADR-0004). This page is the implementer-facing reference for syntax, types, special forms, and evaluation semantics. Cartridge authors and runtime engineers read this to know exactly what the Fe reader and evaluator accept.


Fe is rxi/fe (~800 LOC C, MIT). The vendored copy lives at kn86-emulator/vendor/fe/{fe.h, fe.c}. KN-86 takes Fe largely as-is: the language surface defined here is whatever Fe’s reader and evaluator implement, plus the runtime additions wired through nosh_lisp_bridge.c. There is no fork.

FE_VERSION is "1.0" (per fe.h). KN-86 pins to that release.


Programs are s-expressions. Atoms or parenthesized lists. Lists are heterogeneous; trailing dot syntax for improper pairs is supported by the reader ((a . b) constructs the cons cell a . b).

(fn (x) (* x x))
(let result (cons 1 2))
'(a b c)

Semicolon to end of line is the only comment form Fe recognizes:

; this is a comment
(+ 1 2) ; trailing comment

There is no block-comment syntax.

FormReader producesNotes
123, -4.5, 3.14FE_TNUMBERfe_Number is float (single-precision). Integers up to ~2^24 are exact.
"text"FE_TSTRINGStrings are immutable cons-chains of 7-byte buffers (STRBUFSIZE).
foo, kebab-case-name, +, <=FE_TSYMBOLSymbols are interned in the per-context symlist.
nilFE_TNIL (the singleton &nil)nil is not a symbol — it is the nil sentinel. See “Reader subtleties” below.
'expr(quote expr)Reader sugar; identical to writing (quote expr).

Booleans are not a separate type. nil is false; everything else (including the special truthy singleton ctx->t) is true. Predicates return ctx->t for true and nil for false; fe_bool materializes either.

  • nil vs 'nil. The reader resolves the literal nil (and 'nil) to the nil sentinel object, not to a symbol named "nil". The runtime’s symbol-name extractor (fe_tostring) renders nil as the string "nil", so the bridge’s name-keyed dispatchers (e.g. lisp_event_name_to_slot, see ADR-0012) can still match. Cartridge authors must understand the type difference when debugging unexpected pair shapes.
  • Numbers. Fe’s number type is float. Treat integer arithmetic as exact only inside the [-2^24, 2^24] mantissa window; outside, expect floating-point rounding.
  • Symbols are case-sensitive. Foo and foo are distinct.

The complete type tag set is enumerated in fe.h:

TagLisp surfaceDescription
FE_TPAIRcons cellConstruct via (cons a b) or list literals. The fundamental aggregate.
FE_TFREE(internal)Free-list slot in the arena; never user-visible.
FE_TNILnilThe empty list / false. Singleton.
FE_TNUMBER123, -4.5float.
FE_TSYMBOLfoo, +Interned identifier.
FE_TSTRING"text"Immutable byte sequence.
FE_TFUNC(fn ...) resultUser function (lambda).
FE_TMACRO(mac ...) resultUser macro.
FE_TPRIMif, let, etc.Built-in special form (compiled-in primitive).
FE_TCFUNCNoshAPI bindingsC function wrapped via fe_cfunc and fe_set. The FFI surface lives here.
FE_TPTRopaque handlesBoxed C pointer (e.g. cell handles passed from runtime). Marked with cartridge-supplied mark / gc callbacks.

There are no separate boolean, integer, character, vector, hash-table, or record types. Everything aggregates through pairs and strings. KN-86 record-style structures (deck state, cells) are passed as FE_TPTR opaque handles; field access is via NoshAPI accessor primitives, not Fe-native struct ops.


The following are recognized by the evaluator as special forms (P_* enum in fe.c). These are not functions — they don’t evaluate their arguments by the normal rules. Cartridge authors should think of these as the language; everything else (including builtins like car, cdr, cons, arithmetic) is also implemented as a primitive but follows normal evaluation.

FormShapeSemantics
(let sym value)bindingBind sym to evaluated value in the current scope. Returns value.
(= sym value)assignmentSame shape as let but used for re-assignment to an existing binding.
(if cond then else?)conditionalEvaluate cond. Truthy → evaluate then. Falsy → evaluate else (or return nil). Multiple then/else pairs supported in cond-style chaining.
(fn (params...) body...)lambdaConstruct a closure. Captures lexical environment. Body is a do-style sequence.
(mac (params...) body...)macroConstruct a macro. Argument forms are passed unevaluated; the macro expansion is then evaluated.
(while cond body...)loopIterate while cond evaluates truthy. Returns nil.
(quote x)suppressionReturn x unevaluated. Reader sugar: 'x.
(and a b ...)short-circuitEvaluate left-to-right; return first falsy value, or last value if all truthy.
(or a b ...)short-circuitEvaluate left-to-right; return first truthy value, or nil.
(do expr...)sequenceEvaluate forms in order; return last value. Implicit in function bodies.
(cons a b)constructBuild a pair. (Implemented as primitive for speed; semantically a function.)
(car p) / (cdr p)accessorsFirst / rest of a pair. (Primitives.)
(setcar p v) / (setcdr p v)mutationIn-place pair mutation. Use with care under arena discipline (see memory-model.md).
(list a b ...)constructBuild a list of evaluated values.
(not x)predicateTrue if x is nil.
(is a b)identityReference equality for atoms (numbers compare by value; strings compare structurally).
(atom x)predicateTrue if x is not a pair.
(print x ...)I/OWrite to the configured fe_WriteFn. Used by the REPL; cartridges typically use NoshAPI text primitives instead.
(< a b) / (<= a b)comparisonNumeric.
(+ a b ...) / (- a b ...) / (* a b ...) / (/ a b ...)arithmeticVariadic. Fold left over fe_Number (float).

The complete list of primitive names in source order: let, =, if, fn, mac, while, quote, and, or, do, cons, car, cdr, setcar, setcdr, list, not, is, atom, print, <, <=, +, -, *, / (see primnames[] in fe.c).

  • fn instead of lambda; mac instead of defmacro; = for assignment, let for binding (Fe’s let takes one binding pair, not Scheme’s bindings-list-plus-body shape).
  • No define. Top-level definitions are (= name value) or (let name value).
  • No defun, no defn. Define a function with (= my-fn (fn (x) ...)). Cartridge authoring in this repo uses macros like defcell and defmission — those are runtime-defined, not Fe primitives. See builtins.md and the cartridge authoring docs.
  • No cond, no case. Use nested if or and/or chains; runtime can ship cond as a macro.
  • No tail-call optimization is documented in Fe. Recursive handlers should bound their depth; the GC-stack depth is fixed at GCSTACKSIZE = 256. Long iteration uses while with setcar/setcdr mutation, not deep recursion.

Fe uses standard lexical scoping. fn captures the binding environment at construction time; references to names inside the body look up to the enclosing scopes at call time.

(= make-counter
(fn (start)
(fn ()
(= start (+ start 1))
start)))
(= counter (make-counter 10))
(counter) ; => 11
(counter) ; => 12

The captured environment is a chain of bindings rooted at the global symbol table; closures live in the arena alongside the rest of the cart’s allocations.


Function application: (f a b c) evaluates f, then a, b, c (left-to-right), then invokes f with the evaluated arguments. f may resolve to a FE_TFUNC (user lambda), FE_TCFUNC (FFI binding), FE_TPRIM (special form — these don’t evaluate args by the normal rules), or FE_TMACRO (expanded then re-evaluated).

(mac (params...) body...) constructs a macro. When applied, arguments are bound to params unevaluated; the body runs and produces a new form; that form is then evaluated in the calling environment.

(= unless (mac (cond body) (list 'if cond nil body)))
(unless (is x 0) (do-something))

fe_error(ctx, msg) aborts the current evaluation. The Fe handler chain (fe_handlers(ctx)->error) is invoked first; if it returns, Fe prints the message and a call-stack traceback to stderr and calls exit(EXIT_FAILURE). KN-86 installs a custom error handler that recovers into the REPL prompt rather than exiting.

Out-of-arena raises "out of memory" via the same path. See memory-model.md for arena-exhaustion handling.

Fe maintains a small GC root stack (GCSTACKSIZE = 256). C callers wrap allocation-heavy sections with fe_savegc / fe_restoregc to avoid stack overflow. From within Fe, the GC stack is invisible — overflow shows up as the message "gc stack overflow".

Note: Fe upstream is GC-based, not arena-allocated. The KN-86 integration uses Fe’s mark-sweep over a fixed-size arena allocated at fe_open(ptr, size); per ADR-0004, the resulting envelope behaves arena-like at cart/mission boundaries because we reset the context (fe_close + fe_open) at those points rather than relying on incremental collection. See memory-model.md.


For comparison against Common Lisp / Scheme:

  • No vectors, hash tables, or records (use pairs and FFI handles).
  • No keyword arguments (use plists by convention; the runtime spell-checks via fe_tostring).
  • No format or printf-style formatting (NoshAPI provides text-printf).
  • No eval reflection from inside Fe (fe_eval is a C-only entry point; ADR-0007 forbids exposing it to scripted missions).
  • No standard library beyond what’s in fe.c. Anything else is an FFI binding installed by the runtime (see builtins.md, ../nosh-api/).