tulisp 0.29.0

An embeddable lisp interpreter.
Documentation
# Timer support for tulisp (design notes)

Plan for adding `run-with-timer` / `cancel-timer` to tulisp without new
Rust dependencies. Not yet implemented.

## Constraints

From `CLAUDE.md`:
- std only (no external crates)
- no `unsafe`
- `sync` feature uses `Arc<RwLock>`; default uses `Rc<RefCell>`

`TulispContext` is `!Send` in the default build, so any callback must
ultimately run on the thread that owns the interpreter.

## Options

### A. Cooperative polled timers — recommended

Store a `Vec<Timer>` on `TulispContext`, wrapped in interior mutability
so callbacks can add/cancel timers during `process_timers`.

```rust
struct Timer {
    id: u64,
    deadline: Instant,
    repeat: Option<Duration>,
    callback: TulispObject,
    args: TulispObject,      // already-evaluated arg list
    cancelled: bool,
}
```

Exposed lisp API:
- `(run-with-timer DELAY REPEAT CALLBACK &rest ARGS)` → integer id
  - `DELAY` in seconds (float ok), `REPEAT` nil or seconds
- `(cancel-timer ID)` → t/nil
- `(timer-list)` → optional, for debugging

Rust-side API for the host:
- `ctx.process_timers() -> Result<usize, Error>` — fires every timer
  whose deadline has passed, reschedules repeaters, returns how many
  fired. Host calls this once per main-loop iteration.
- `ctx.next_timer_deadline() -> Option<Instant>` — lets the host cap
  its poll/sleep timeout so it wakes in time.

Sketch of `process_timers`:
1. `let now = Instant::now();`
2. Borrow the timers vec, collect ready (non-cancelled, deadline ≤ now)
   into a local `Vec<Timer>`, drop the borrow. This is the key move:
   `ctx.eval` during callback invocation will need `&mut self`, and we
   must not hold the inner borrow across it.
3. For each ready timer, call `funcall` with `callback` + `args`.
4. Re-borrow timers, push repeaters back with `deadline += repeat`,
   drop any that were cancelled mid-callback.

Handle to storage lives as `Rc<RefCell<TimerState>>` on the context (or
`Arc<RwLock<…>>` under `sync`). Use the existing `Shared` wrapper so
the same code compiles either way.

Roughly 80 lines + tests. No threads.

Tradeoff: callback precision is bounded by the host's poll cadence. For
a 250 ms TUI loop, timers fire accurate to ~250 ms. For microsim's
dashboard and for the existing `every`-based simulator ticks, that's
more than enough.

### B. Thread + mpsc wakeup

Same `Timer` struct and same lisp API. Add a std::thread that sleeps
until the nearest deadline and pushes on an `mpsc::Sender<()>` that the
host selects on alongside its event source.

Callbacks still run on the main thread (required — `TulispContext` is
`!Send` without the `sync` feature, and even with it, a single writer
model is easier to reason about). The thread exists only to translate
"deadline reached" into "wake the main loop now."

Pros: sub-poll-interval accuracy; host doesn't have to think about
`next_timer_deadline`.

Cons:
- Add/cancel must re-wake the scheduler thread (channel send or a
  `Condvar`). Classic timer-wheel sync dance; bugs live here.
- Shutdown path has to cleanly drop the scheduler thread.
- ~150 lines, plus a real test story for the threading.

Skip unless sub-100 ms timer accuracy actually matters. For microsim
it doesn't.

### C. Async timers

Would pull `tokio` (or equivalent). Violates the no-dep rule. Out.

## Recommendation

Ship **A**. It matches tulisp's single-threaded, no-dep posture, folds
microsim's existing application-level `every` macro down into the
interpreter, and gives us a proper `cancel-timer` which `every`
doesn't have.

## Notes / gotchas

- `funcall` takes a pre-evaluated callback and an already-evaluated
  args list. `run-with-timer` should use `defspecial` so it can
  evaluate `DELAY`/`REPEAT`/`CALLBACK` but leave the callback body
  unevaluated, then pre-build the args list once (see how `mapcar`
  handles this).
- The ids should come from a monotonic `u64` counter on the timer
  state, never reused. `cancel-timer` on an unknown id is a no-op
  returning nil, not an error.
- `process_timers` must not panic if a callback raises; propagate the
  error back to the host after reinserting any still-live repeaters so
  the next tick is in a clean state.
- Test strategy: inject a fake clock (`trait Clock` on the timer
  state) so tests don't have to sleep. Same pattern as stdlib's
  `Instant` replacement in other no-dep projects.
- Under `--features sync`, the timer vec lives behind `Arc<RwLock>`
  like everything else. No extra work if we use the existing `Shared`
  alias.