Skip to content

Implement and migrate to a VM - #63

Merged
shsms merged 252 commits into
mainfrom
vm
May 1, 2026
Merged

Implement and migrate to a VM#63
shsms merged 252 commits into
mainfrom
vm

Conversation

@shsms

@shsms shsms commented Dec 29, 2023

Copy link
Copy Markdown
Owner

Also includes several other features

shsms added 30 commits December 15, 2023 21:51
Also improve error messages in `compile_2_arg_call`.
Also add compilers for some arithmetic and comparison operators.
shsms added 26 commits April 30, 2026 21:46
`(length L)` walked `L`'s cdr chain unconditionally, so a
self-referential list infloop'd. With `setcdr` now reachable from
Lisp (a13), the trivial repro is `(let ((l (list 1 2 3))) (setcdr
(cddr l) l) (length l))` — used to hang the interpreter.

Replace `length`'s walk with a tortoise / hare loop: hare advances
two cells per step and on cycle detection lands on the same cell
as the tortoise, at which point we raise `OutOfRange: Circular
list` instead of hanging.

Other walkers (`reverse`, `memq`, `assoc_find`, `plist_get`,
`last`) still infloop on cycles — left under a22 until a shared
cycle-detecting walker exists.
Two related Emacs-Lisp surface gaps closed in one pass:

- `?X` reads as `X`'s Unicode code point (`?A` => 65). Backslash
  escapes mirror string literals (`?\n`, `?\t`, `?\\`, `?\"`, `?\r`,
  `?\b`, `?\f`, `?\v`, `?\a`, `?\e`, `?\0`); unknown escapes pass
  through (`?\j` => `j`), matching Emacs' lenient reader.
- `#x10` / `#xff` / `#X10` (hex), `#o10` (octal), `#b1010` (binary)
  read as integers via `i64::from_str_radix`. An optional `+` / `-`
  may appear between the prefix and the first digit (`#x-10` => -16,
  also matching Emacs).

Together these unlock the common idiom `(aset s 0 ?Z)` for
single-char string mutation.
A closure compiled in one ctx had its `Instruction::Label`
positions registered into that ctx's `vm.labels` via
`MakeLambda`. Invoked through a different ctx — the canonical
case being `tulisp-async`'s per-firing timer ctx, but any host
that does `ctx.funcall` of a foreign closure hits it — that ctx's
`vm.labels` is empty. `cond` / `and` / `or` (which compile to
`Pos::Label` jumps) panicked in `jump_to_pos!` on
`vm.labels.get(...).unwrap()`.

Switchyard's CLAUDE.md flagged this as "cond in (run-with-timer)
body panics — use nested if instead." The restriction can lift
now: register the closure's labels at every `run_lambda` /
`run_lambda_with` entry, not just at `MakeLambda` time. Idempotent
(same key, same value) for the same-ctx case, fixes the cross-ctx
case.

Regression test invokes a closure containing `cond` / `and` / `or`
through a freshly-constructed ctx — used to panic before the fix,
passes after.
Lets external users (and downstream modules) parametrize conversion
paths over an evaluation strategy. Re-exported from the crate root
so callers can write `tulisp::DummyEval` directly.

No behavioral change — just visibility and a doc comment.
Pulls plist_from and plist_get out of lists.rs into a dedicated
plist module. The typed Plistable layer (still in src/context/plist.rs
for now) will join them in the next commit.

Mechanical follow-ups:
- callers in builtin/functions/core.rs and bytecode/interpreter.rs
  switch from `lists::plist_get` to `plist::plist_get`.
- the AsPlist! macro body now references `$crate::plist::plist_from`.
- the plist test moves with the code.
Pulls Plistable, Plist<T>, and the AsPlist! macro out of
src/context/plist.rs and into src/plist.rs alongside plist_from /
plist_get. The two layers are now in one place — typed conversion
sits next to the raw helpers it builds on.

The crate-root re-exports (Plist, Plistable) shift from `context::`
to `plist::`, so external users see no API change.
The trait gains a type parameter `E: Evaluator = DummyEval` so the
same struct can be deserialized from either an already-evaluated lisp
plist (`Plistable<DummyEval>`, the default) or a plist of unevaluated
forms (`Plistable<Eval>`).

The AsPlist! macro now generates a blanket `impl<E: Evaluator>
Plistable<E> for ...`, with each value extracted via
`E::eval(ctx, value)?.into_owned().try_into()?`. `Plist<T>` (the
defun-arg wrapper) and the corresponding `TulispCallable` bounds
pin the strategy to `Eval` to preserve current call semantics.

External users now have a no-eval entry point — given a `TulispObject`
holding an already-evaluated plist, write
`<MyType as Plistable>::from_plist(ctx, &obj)` to convert without
re-evaluating each value.
The trait was generic (`Plistable<E: Evaluator = DummyEval>`) so
`Person::from_plist(...)` would be ambiguous — every `E` produced a
distinct trait, and method resolution couldn't pick one without an
explicit `<Person as Plistable>::` qualifier.

Move the parameter onto the method instead: `from_plist_with<E>` is
the implementation hook the AsPlist! macro fills in, and `from_plist`
is a default trait method that delegates to
`from_plist_with::<DummyEval>`. The trait itself becomes non-generic,
so the call site is just `MyType::from_plist(ctx, &obj)`.

`Plist::new` now calls `T::from_plist_with::<Eval>` directly; the
`TulispCallable` bounds drop back to `T: Plistable`.
Pulls alist_from, alist_get, assoc, and assoc_find out of lists.rs
into a dedicated alist module — same shape as the earlier
plist split. The typed Alistable layer will land alongside them
in the next commit.

Mechanical follow-ups: callers in builtin/functions/core.rs switch
from `lists::assoc` / `lists::alist_get` to `crate::alist::*`. The
alist test moves with the code; the unused `crate::lists` import in
core.rs is removed.
Mirrors the Plistable / AsPlist! shape, adapted for alist structure
(list of dotted cons pairs instead of a flat key/value list). The
trait is non-generic — `from_alist_with<E>` is the implementation
hook, `from_alist` is a default-method shortcut for DummyEval, and
`into_alist(self, ctx)` interns the keys.

Unlike Plistable, Alistable does not auto-derive TulispConvertible:
into_tulisp lacks the &mut TulispContext needed to intern symbol
keys, and tulisp's symbol equality is identity-based, so a
non-interned key wouldn't match `(alist-get …)` in lisp. Construct
return alists through into_alist or alist_from directly.

A new AlistError ErrorKind keeps malformed-alist errors distinct from
the existing PlistError; condition-case maps both to
`wrong-type-argument`.
The macro arms for `{= None}` / `{= Some(...)}` defaults previously
expanded to `Some(eval(value)?.try_into()?)`, where the inner
`try_into()` resolves to `T` (e.g. `String`), not `Option<T>`. Passing
explicit `nil` for an optional field then errored with
"expected string, got: nil", and round-tripping a `None` field
through `into_plist`/`into_alist` (both serialize None as nil) failed
to deserialize.

Both macros now check `value.null()` first and short-circuit to
`None`, taking the `try_into::<T>` path only for non-nil values.
Existing "key absent → default kicks in" behavior is unchanged.

To make the fix reach the defun-arg path on the plist side,
`Plist::new` switches from `Plistable<Eval>` to the `from_plist`
default (DummyEval). Defun args are pre-evaluated by the typed-defun
machinery, so the inner re-eval was always a no-op disguised by
`build_plist_obj`'s quote-wrapping. With DummyEval, the wrap is no
longer needed — `build_plist_obj` just lays the args out
key/value/key/value, and the macro sees the actual values (including
real `nil`) instead of `(quote nil)`.

Tests: explicit `:edu nil` for plist now resolves the optional to
None (Unknown via the test's defaulting), and Alistable now round-
trips through `into_alist` / `from_alist` cleanly.
`Plist::new` no longer needs `Eval` mode (defun args are pre-evaluated
before they reach the typed-defun closure), so the trait's generic
`from_plist_with<E>` is dead surface — every internal caller went
through the `DummyEval` path.

Drop the generic. The implementation hook becomes
`from_plist_iter(ctx, impl Iterator<Item = TulispObject>)`:

  - `from_plist(ctx, &obj)` (default method) hands `obj.base_iter()`
    in directly — no intermediate `Vec`.
  - `Plist::new(ctx, &[TulispObject])` (the typed-defun path) calls
    with `args.iter().cloned()` — one cheap refcount-inc per arg
    instead of cons-cell reassembly via `build_plist_obj`.

`build_plist_obj` is gone, the macro's `@extract-field` arms simplify
to `value.try_into()?` (no E::eval indirection), and the callable.rs
plist_args module shrinks. The `Evaluator`/`Eval`/`DummyEval` types
remain pub for now — they'll be reverted to pub(crate) once Alistable
loses its generic too (next commit).
Each entry path now has its own concrete implementation, tuned for
how its caller delivers values:

  - `from_plist_as_slice(ctx, &[TulispObject])` is what `Plist::new`
    (the typed-defun path) calls. Walks the slice via `as_chunks::<2>()`
    with `&TulispObject` borrows; clones the value only inside the
    matching `if`-arm (no clone for non-matching keys, which become
    "unexpected key" errors).

  - `from_plist(ctx, &TulispObject)` is the free-variable / lisp-value
    path. Drives `obj.base_iter()` directly and pulls owned
    `TulispObject`s in pairs via `while let Some(key) = iter.next()` —
    no intermediate `Vec`, no per-arg clone.

The Builder `struct`/`impl` is duplicated between the two methods;
the bodies otherwise share shape. The trait is no longer generic
over `Evaluator`, and the macro's `@extract-field` arms drop the
`E::eval` indirection — values arrive already evaluated.
`from_alist_with<E>` was added by symmetry with `Plistable`'s
generic, but no caller ever exercised the `Eval` mode — alists never
flow through the typed-defun pre-eval path (Alistable types aren't
TulispConvertible), and from-a-free-variable always wants
`DummyEval`. The trait collapses to a single non-generic method:

    fn from_alist(ctx, obj) -> Result<Self, Error>;
    fn into_alist(self, ctx) -> TulispObject;

The macro generates `from_alist` directly. `@extract-field` arms drop
the `E::eval` indirection and use `value.try_into()?` (or the
`null()` short-circuit for Optional fields). The shadow that
re-borrowed `value_owned` as `&value_owned` is gone — `entry.cdr()?`
returns owned `TulispObject` and the macro consumes it.
Bringing them up to pub was motivated by Plistable's
`from_plist_with<E>` generic. With both Plistable and Alistable
having dropped that generic, no public API references these types
anymore — `funcall<E>` and `eval_form<E>` (the remaining users) are
crate-internal. Drop the lib.rs re-export and shrink the public
surface back to where it was.
The Alistable trait doc was rewritten when the Evaluator generic
existed; cleaning it up now:

  - drop references to `from_alist_with::<Eval>` (the method is gone)
  - drop references to the `Evaluator` type (now `pub(crate)` again)
  - replace the misleading "no auto-derived TulispConvertible" line
    with the actual contrast — `Alistable` has no `Alist<T>` wrapper
    because alists arrive as a single arg, not a flat keyword list

The plist test comment referenced `Plistable<DummyEval>`, which is
also dead terminology — replace with the current shape.
`assoc_find` walked the alist with a single pointer, so a circular
alist (e.g. one built via `setcdr`) would infloop both `assoc` and
`alist-get`. Match `lists::length`'s Floyd's tortoise / hare: the
hare advances two cells per outer iteration (testing each), the
tortoise advances one, equality on cell address ends the loop.

Reuses the existing `OutOfRange` ErrorKind with "Circular alist" — same
shape as `length`'s "Circular list" message.
Same hazard `assoc_find` had — a circular plist (built via `setcdr`)
infloops a naive walker. Add Floyd's tortoise / hare on top of the
existing `cddr` step (the natural 2-cell hare advance for a plist),
with the tortoise advancing by `cdr` once per iteration. Errors with
"Circular plist", paralleling `length`'s "Circular list" and
`assoc_find`'s "Circular alist".
Mirrors the Alistable coverage. Verifies the nil-handling fix on the
plist side too — a `Person { education: None, .. }` serialises with
an `:edu nil` entry, and `from_plist` reads that back as `None`
instead of erroring on `try_into::<String>()`.
The README's existing AsPlist! section is correct but only describes
the defun-arg path. Two additions:

  - A short note that an `Option<T>` field reads explicit `nil` as
    `None` (the recently-fixed behavior), and a snippet showing
    `Plistable::from_plist` for plists held in free variables rather
    than collected from a defun call.

  - A new "Alist-shaped structs with `AsAlist!`" section covering the
    Alistable trait, its `from_alist`/`into_alist` methods, and why
    there's no `Alist<T>` wrapper analogous to `Plist<T>` (alists
    arrive as a single value, not as a flat keyword list).

The Alistable example is a runnable doctest; the from_plist snippet
is `rust,ignore` because it requires fallible setup outside the
doctest's `fn main` shim.
The four code examples under "Exposing Rust functions" now collapse to
a paragraph naming the supported argument types and shapes (`&optional`
via `Option<T>`, `&rest` via `Rest<T>`, fallible via `Result<T, Error>`,
ctx-as-first-arg, `TulispConvertible` for custom types) — readers click
through to the docs for full signatures.

The `Opaque Rust values` section's full `TulispConvertible` impl was
duplicating the canonical example in the trait's own rustdoc; folded
into a one-line mention inside the "Exposing Rust functions" paragraph.

`Keyword-argument` and `Alist-shaped` were two adjacent sections with
parallel structure; merged into one "Keyword-argument and
alist-shaped structs" section, with `AsAlist!`/`Alistable` covered as
a mirror of `AsPlist!`/`Plistable` rather than a full repeated
example.

`Built-in Lisp features` was an exhaustive bullet list that repeated
the `builtin` module's own listing; replaced with a paragraph naming
the categories and pointing to the module docs.

Net: 165 lines removed, 46 added.
Brings the function/macro tables back in line with what's actually
registered:

  - Conditionals: add `if-let*` (the missing sibling of `if-let`); fix
    misaligned padding on `not`/`and`/`or`/`xor`; close the broken
    `if-let` row.
  - Accessing Elements of Lists: add `setcar`, `setcdr`; drop `last`'s
    "not cycle-safe" detail (it errors on circular lists now via the
    cycle-safe `length`); rename misspelled `safe-lentgh` → `safe-length`.
  - Sequence Functions: add `aset` (mutates a string in place);
    note that `length` is now cycle-safe.
  - Hash Tables: note `gethash`'s optional 3rd `default` argument.
  - Time Calculations: fix `test-subtract`/`test-add` typos in prose
    (table was already correct).
  - Math Functions: add `isnan`.
  - Error handling: add `condition-case`.
  - New "Threading macros" section listing `->`, `->>`, `thread-first`,
    `thread-last`.

Also fixes a stale rustdoc intra-doc link in `context.rs` —
`[Plist<T>](Plist)` resolved against `context::Plist` before the
plist module moved up to the crate root; switch to `crate::Plist`.
The table-with-checkmarks format had two recurring problems: the
"Status" column was redundant for user-facing docs (everything
documented should be implemented), and the 18 "not implemented"
rows were maintainer roadmap notes that rotted faster than anyone
remembered to update them — the previous commit had to fix several.

Replace it with compact categorized prose. One paragraph per category,
names inline as backticks, with semantic notes ("errors on circular
lists", "behaves like princ", "uses banker's rounding") inline where
they matter. Categories follow the Emacs Lisp manual layout: Numbers,
Strings, Lists, Symbols and variables, Functions and macros, Control
flow, Predicates, Hash tables, Time, Errors.

Drops the per-section Emacs-manual links — they were noisy and pointed
to a page the names already imply. A single reference to the manual at
the top of the doc covers it.

Net: 240 lines removed, 68 added. Maintenance burden drops to "edit
one paragraph when adding a builtin" instead of "add a row, pick
pretty padding, decide which section the new function lives in".
`(apply FUNCTION &rest ARGUMENTS)` — call FUNCTION with the
intermediate ARGUMENTS plus the elements of the final list (which
must itself evaluate to a list):

    (apply '+ 1 2 '(3 4))   =>  10
    (apply 'list '())       =>  nil
    (apply (lambda (a b) (* a b)) 3 '(4))  =>  12

Mirrors `funcall`'s split between TW and VM:

  - TW: a `defspecial` in `core.rs` evaluates each rest arg, splices
    the final list in, wraps the spliced values in `quote` so the
    inner Eval-mode funcall is a no-op, and dispatches with the same
    Lambda/Defun/CompiledDefun → Eval, otherwise → DummyEval branching
    that `funcall` uses.

  - VM: a new `Instruction::Apply { args_count }` and matching
    `compile_fn_apply` in `compiler/forms/lambda.rs`. Compiles to:
    push fn, push intermediate args, push final list, then `Apply`.
    The runtime handler pops the final list, validates it's a list,
    drains the intermediate args, then dispatches via
    `funcall_inline` — same in-VM path as `Funcall`.

Errors when the final arg isn't a list, or when fewer than two args
were supplied (matching Emacs's "Wrong number of arguments" on
`(apply '+)`).

Tests in `tests/tests.rs::test_apply` cover both paths via
`tulisp_assert!`. Builtin module doc mentions `apply` alongside
`funcall` and `eval` under "Invocation".
Two issues caught during audit:

**Improper trailing list silently dropped its tail.** Both paths
iterated `final_list` via `while iter.consp()` and stopped at any
non-cons cdr, so `(apply '+ 1 '(2 3 . 4))` returned 6 instead of
erroring. Emacs raises `(wrong-type-argument listp 4)` for this; we
now error with "apply: last argument must be a proper list, got
non-nil tail: 4" (the trailing element formatted into the message).

**`(apply)` errored differently between TW and VM.** The VM's
compile-time path returned `TypeMismatch: apply requires at least
2 arguments`; the TW defspecial fell through to `destruct_bind!`'s
generic "Too few arguments" `MissingArgument`. Both paths now check
for a nil arg list explicitly and raise the same `MissingArgument`
with the same message. Cleared up the corresponding `TypeMismatch`
in `compile_fn_apply`'s no-args branch (kept `MissingArgument` from
the existing `total_args == 0` check too — they were already the
same kind, just different paths).

Tests added: improper-tail rejection (new), `(apply)` no-args case
(new — both paths exercised by `tulisp_assert!`).
The previous patch's `while iter.consp()` splice loop hangs forever
when the trailing list is circular — the same hazard `length`,
`assoc_find`, and `plist_get` already had patches for. Replace both
TW and VM splice loops with Floyd's tortoise / hare: hare advances
two cells per outer iteration (pushing each), tortoise advances by
one, and equality on cell address ends the loop with
"OutOfRange: apply: last argument is a circular list".

The improper-tail check moves to the single break point at the
bottom of the outer loop — placing it inside the inner loop, as the
last patch did, missed the case where `cdr()` lands on a non-cons
non-nil value at the end of a successful inner iteration. Tests:
the existing improper-tail test still passes; a new cycle test
exercises both paths via `tulisp_assert!`.
@shsms shsms changed the title vm Implement and migrate to a VM May 1, 2026
@shsms
shsms marked this pull request as ready for review May 1, 2026 12:32
@shsms
shsms merged commit 91adf6d into main May 1, 2026
2 checks passed
@shsms
shsms deleted the vm branch May 1, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant