Conversation
Also improve error messages in `compile_2_arg_call`.
Also add compilers for some arithmetic and comparison operators.
`(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
marked this pull request as ready for review
May 1, 2026 12:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Also includes several other features