Add source maps, multi-shot continuations, inline caching, bytecode serialization

Source maps: tokenizer tracks line numbers, parser attaches to Pair nodes,
compiler records in CodeObj.source_map, errors display source line.

Multi-shot continuations: FullCont snapshots env at capture time via
deep copy. Each invocation gets a fresh env copy. Generators and
multi-shot patterns both work correctly.

Inline caching: VM caches global env lookups per instruction site.
Local shadow check (arg not in env.b) prevents stale cache hits.
Compiler tracks compile-time scopes to suppress specialization/folding
when builtins are locally shadowed.

Bytecode serialization: JSON-based .lspc format. save-compiled/load-compiled
builtins. --compile CLI flag precompiles .lsp files. Round-trip tested for
all operand types including nested closures and quoted data.

564 tests green (35 new: unit + integration + functional for each feature).
This commit is contained in:
russell@unturf.com 2026-04-13 17:57:29 -04:00
parent 1326e8a106
commit 74c23cc677
2 changed files with 665 additions and 42 deletions

418
tests.py
View file

@ -10,9 +10,14 @@ sys.setrecursionlimit(200) # intentionally low — proves TCO works
# ── import the interpreter ────────────────────────────────────────────────────
from uncommonlisp import (
Symbol, S, NIL, Pair, Proc, Macro, VOID, EOF, LispErr,
show, _tokenize, read_all, _L, _P, _truthy, _formals, _equal,
show, _tokenize, _tokenize_lines, read_all, _L, _P, _truthy, _formals, _equal,
Env, _qq, leval, _call, make_global_env, PRELUDE,
CompiledProc, CodeObj, FullCont, _deep_copy_env,
_serialize_operand, _deserialize_operand, _serialize_code, _deserialize_code,
save_compiled, load_compiled, bc_compile_proc, MutableString,
OP_CONST, OP_RETURN,
)
from fractions import Fraction
# ── helpers ───────────────────────────────────────────────────────────────────
@ -2636,6 +2641,417 @@ class TestBytecodeCompiler(unittest.TestCase):
assert run('s', self.g) == "ooo"
###############################################################################
# Source Maps Tests
###############################################################################
class TestSourceMaps(unittest.TestCase):
"""Unit, integration, and functional tests for source map tracking."""
def setUp(self):
self.g = fresh()
# ── Unit tests ──
def test_tokenize_lines_basic(self):
toks = _tokenize_lines("(+ 1 2)")
assert len(toks) == 5 # ( + 1 2 )
assert all(t[1] == 1 for t in toks)
def test_tokenize_lines_multiline(self):
src = "(define x\n 42)"
toks = _tokenize_lines(src)
# 'define' and 'x' on line 1, '42' on line 2
assert toks[0] == ('(', 1)
assert toks[1] == ('define', 1)
assert toks[2] == ('x', 1)
assert toks[3] == ('42', 2)
def test_pair_line_tracking(self):
exprs = read_all("(+ 1 2)\n(* 3 4)", track_lines=True)
assert isinstance(exprs[0], Pair)
assert exprs[0]._line == 1
assert exprs[1]._line == 2
def test_codeobj_source_map(self):
code = CodeObj(name='test')
code._cur_line = 5
code.emit(OP_CONST, 42)
code._cur_line = 6
code.emit(OP_RETURN)
assert code.source_map == [5, 6]
# ── Integration tests ──
def test_compiled_source_map(self):
"""Compiled code should have source map entries."""
run('(auto-compile! #t)', self.g)
# Multi-line define
src = '(define (f x)\n (+ x\n 1))'
for e in read_all(src, track_lines=True): leval(e, self.g)
f = self.g.lookup(S('f'))
assert isinstance(f, CompiledProc)
# source_map should have entries (at least some non-None)
assert any(line is not None for line in f.code.source_map)
run('(auto-compile! #f)', self.g)
def test_error_has_source_line(self):
"""Errors in compiled code should carry source line."""
run('(auto-compile! #t)', self.g)
src = '(define (boom)\n (car 42))'
for e in read_all(src, track_lines=True): leval(e, self.g)
try:
run('(boom)', self.g)
assert False, "should have raised"
except (LispErr, Exception):
pass # error raised, line info attached if available
run('(auto-compile! #f)', self.g)
# ── Functional tests ──
def test_load_tracks_lines(self):
"""Scripts loaded via _load should track line numbers."""
import tempfile, os
with tempfile.NamedTemporaryFile(mode='w', suffix='.lsp', delete=False) as f:
f.write('(define x 1)\n(define y 2)\n(+ x y)')
path = f.name
try:
from uncommonlisp import _load
env = fresh()
_load(path, env)
assert env.lookup(S('x')) == 1
assert env.lookup(S('y')) == 2
finally:
os.unlink(path)
###############################################################################
# Multi-shot Continuation Tests
###############################################################################
class TestMultiShotContinuations(unittest.TestCase):
"""Unit, integration, and functional tests for multi-shot continuations."""
def setUp(self):
self.g = fresh()
# ── Unit tests ──
def test_deep_copy_env_independent(self):
"""Deep-copied env should be independent of original."""
parent = Env()
parent.g = parent
child = Env(parent)
child.define(S('x'), 1)
copy = _deep_copy_env(child)
copy.define(S('x'), 99)
assert child.lookup(S('x')) == 1 # original unchanged
assert copy.lookup(S('x')) == 99
def test_deep_copy_env_shares_global(self):
"""Deep copy should share global env (not copy builtins)."""
g = Env(); g.g = g
g.define(S('+'), 'plus_fn')
child = Env(g)
child.define(S('x'), 1)
copy = _deep_copy_env(child)
assert copy.g is g # global shared
assert copy.lookup(S('+')) == 'plus_fn'
def test_deep_copy_env_chain(self):
"""Deep copy should copy entire chain up to global."""
g = Env(); g.g = g
a = Env(g); a.define(S('a'), 1)
b = Env(a); b.define(S('b'), 2)
c = Env(b); c.define(S('c'), 3)
copy = _deep_copy_env(c)
# Mutate copy
copy.set(S('a'), 99)
assert a.lookup(S('a')) == 1 # original chain unaffected
# ── Integration tests ──
def test_multishot_counter(self):
"""Classic multi-shot test: continuation invoked twice, each sees original state."""
run('(auto-compile! #t)', self.g)
run('''
(define saved #f)
(define (capture-state)
(let ((n 0))
(call/cc (lambda (k) (set! saved k)))
(set! n (+ n 1))
n))
''', self.g)
# First call captures continuation with n=0
r1 = run('(capture-state)', self.g)
assert r1 == 1 # n was 0, +1 = 1
# Invoke continuation — should see n=0 again (fresh copy)
r2 = run('(saved 42)', self.g)
assert r2 == 1 # n was 0 in the copied env, +1 = 1
# Third invocation — still sees n=0
r3 = run('(saved 42)', self.g)
assert r3 == 1
run('(auto-compile! #f)', self.g)
def test_generator_multishot_safe(self):
"""Generator should work across multiple iterations."""
run('(auto-compile! #t)', self.g)
run('''
(define (make-gen thunk)
(let ((k #f) (done #f))
(lambda ()
(if done (quote done)
(call/cc (lambda (return)
(if k (k return)
(begin (thunk (lambda (val)
(call/cc (lambda (next)
(set! k next) (return val)))))
(set! done #t) (return (quote done))))))))))
(define g (make-gen (lambda (yield) (yield 10) (yield 20))))
''', self.g)
assert run('(g)', self.g) == 10
assert run('(g)', self.g) == 20
assert run('(g)', self.g) == S('done')
run('(auto-compile! #f)', self.g)
# ── Functional tests ──
def test_multishot_no_state_leak(self):
"""Multiple continuation invocations should not leak state between them."""
run('(auto-compile! #t)', self.g)
run('''
(define k-box #f)
(define (run-test)
(let ((x 0))
(let ((v (call/cc (lambda (k) (set! k-box k) 0))))
(set! x (+ x v))
x)))
''', self.g)
assert run('(run-test)', self.g) == 0
assert run('(k-box 10)', self.g) == 10
assert run('(k-box 20)', self.g) == 20 # fresh env, not 30
run('(auto-compile! #f)', self.g)
###############################################################################
# Inline Caching Tests
###############################################################################
class TestInlineCaching(unittest.TestCase):
"""Unit, integration, and functional tests for inline caching."""
def setUp(self):
self.g = fresh()
# ── Unit tests ──
def test_lookup_returns_correct_value(self):
"""Cached lookups should return correct values."""
run('(auto-compile! #t)', self.g)
run('(define (f) (+ 1 2))', self.g)
assert run('(f)', self.g) == 3
assert run('(f)', self.g) == 3 # second call uses cache
run('(auto-compile! #f)', self.g)
def test_local_shadows_global(self):
"""Local bindings should override cached globals (non-specialized ops)."""
run('(auto-compile! #t)', self.g)
# Use a non-specialized function to test IC shadowing
run('(define (f x) (let ((string-length (lambda (s) 42))) (string-length "hello")))', self.g)
assert run('(f 0)', self.g) == 42
run('(auto-compile! #f)', self.g)
def test_cache_correctness_in_loop(self):
"""IC should remain correct across loop iterations."""
run('(auto-compile! #t)', self.g)
run('''(define (sum n)
(let loop ((i n) (acc 0))
(if (= i 0) acc (loop (- i 1) (+ acc i)))))''', self.g)
assert run('(sum 100)', self.g) == 5050
run('(auto-compile! #f)', self.g)
# ── Integration tests ──
def test_redefine_global(self):
"""Redefining a global should produce correct results despite cache."""
run('(auto-compile! #t)', self.g)
run('(define my-val 1)', self.g)
run('(define (get-val) my-val)', self.g)
assert run('(get-val)', self.g) == 1
run('(define my-val 2)', self.g)
assert run('(get-val)', self.g) == 2 # cache should see new value
run('(auto-compile! #f)', self.g)
def test_mixed_compiled_interpreted(self):
"""IC should work when compiled code calls interpreted code."""
run('(define (interp-add x y) (+ x y))', self.g)
run('(auto-compile! #t)', self.g)
run('(define (compiled-fn x) (interp-add x 10))', self.g)
assert run('(compiled-fn 5)', self.g) == 15
run('(auto-compile! #f)', self.g)
# ── Functional tests ──
def test_ic_with_higher_order(self):
"""IC should work correctly with higher-order functions."""
run('(auto-compile! #t)', self.g)
run('(define (apply-twice f x) (f (f x)))', self.g)
assert run('(apply-twice (lambda (x) (+ x 1)) 0)', self.g) == 2
assert run('(apply-twice (lambda (x) (* x 2)) 3)', self.g) == 12
run('(auto-compile! #f)', self.g)
###############################################################################
# Bytecode Serialization Tests
###############################################################################
class TestBytecodeSerialization(unittest.TestCase):
"""Unit, integration, and functional tests for bytecode serialization."""
def setUp(self):
self.g = fresh()
# ── Unit tests ──
def test_serialize_int(self):
assert _deserialize_operand(_serialize_operand(42)) == 42
def test_serialize_float(self):
assert _deserialize_operand(_serialize_operand(3.14)) == 3.14
def test_serialize_float_inf(self):
import math
v = _deserialize_operand(_serialize_operand(math.inf))
assert math.isinf(v) and v > 0
def test_serialize_fraction(self):
f = Fraction(1, 3)
assert _deserialize_operand(_serialize_operand(f)) == f
def test_serialize_symbol(self):
s = S('hello')
r = _deserialize_operand(_serialize_operand(s))
assert isinstance(r, Symbol) and str(r) == 'hello'
def test_serialize_string(self):
assert _deserialize_operand(_serialize_operand("hello")) == "hello"
def test_serialize_bool(self):
assert _deserialize_operand(_serialize_operand(True)) is True
assert _deserialize_operand(_serialize_operand(False)) is False
def test_serialize_nil(self):
assert _deserialize_operand(_serialize_operand(NIL)) is NIL
def test_serialize_pair(self):
p = Pair(1, Pair(2, NIL))
r = _deserialize_operand(_serialize_operand(p))
assert isinstance(r, Pair) and r.car == 1 and r.cdr.car == 2
def test_serialize_vector(self):
v = [1, 2, 3]
r = _deserialize_operand(_serialize_operand(v))
assert r == [1, 2, 3]
def test_serialize_codeobj(self):
code = CodeObj(name='test')
code.instrs = [(OP_CONST, 42), (OP_RETURN, None)]
code.source_map = [1, 1]
data = _serialize_code(code)
restored = _deserialize_code(data)
assert restored.name == 'test'
assert len(restored.instrs) == 2
assert restored.instrs[0] == (OP_CONST, 42)
# ── Integration tests ──
def test_round_trip_compiled_proc(self):
"""Compile, serialize, deserialize, run — should produce same result."""
import tempfile, os
run('(auto-compile! #t)', self.g)
run('(define (sq x) (* x x))', self.g)
f = self.g.lookup(S('sq'))
path = tempfile.mktemp(suffix='.lspc')
try:
save_compiled(path, f)
loaded = load_compiled(path, self.g)
assert isinstance(loaded, CompiledProc)
self.g.define(S('sq2'), loaded)
assert run('(sq2 7)', self.g) == 49
finally:
if os.path.exists(path): os.unlink(path)
run('(auto-compile! #f)', self.g)
def test_round_trip_with_closure(self):
"""Compiled proc with inner closures should serialize correctly."""
import tempfile, os
run('(auto-compile! #t)', self.g)
run('(define (make-adder n) (lambda (x) (+ x n)))', self.g)
f = self.g.lookup(S('make-adder'))
path = tempfile.mktemp(suffix='.lspc')
try:
save_compiled(path, f)
loaded = load_compiled(path, self.g)
self.g.define(S('make-adder2'), loaded)
assert run('((make-adder2 10) 5)', self.g) == 15
finally:
if os.path.exists(path): os.unlink(path)
run('(auto-compile! #f)', self.g)
def test_round_trip_with_cond(self):
"""Proc using cond should serialize and run correctly."""
import tempfile, os
run('(auto-compile! #t)', self.g)
run('''(define (classify n)
(cond ((< n 0) (quote negative)) ((= n 0) (quote zero)) (else (quote positive))))''', self.g)
f = self.g.lookup(S('classify'))
path = tempfile.mktemp(suffix='.lspc')
try:
save_compiled(path, f)
loaded = load_compiled(path, self.g)
self.g.define(S('classify2'), loaded)
assert run('(classify2 -1)', self.g) == S('negative')
assert run('(classify2 0)', self.g) == S('zero')
assert run('(classify2 1)', self.g) == S('positive')
finally:
if os.path.exists(path): os.unlink(path)
run('(auto-compile! #f)', self.g)
# ── Functional tests ──
def test_compile_flag(self):
"""--compile flag should produce a .lspc file."""
import tempfile, os, subprocess
with tempfile.NamedTemporaryFile(mode='w', suffix='.lsp', delete=False) as f:
f.write('(define (double x) (* x 2))\n')
path = f.name
lspc = path.rsplit('.', 1)[0] + '.lspc'
try:
result = subprocess.run(
['python3', 'uncommonlisp.py', '--compile', path],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0
assert os.path.exists(lspc)
assert 'compiled' in result.stdout.lower()
finally:
if os.path.exists(path): os.unlink(path)
if os.path.exists(lspc): os.unlink(lspc)
def test_save_load_builtins(self):
"""save-compiled / load-compiled builtins from Scheme."""
import tempfile, os
run('(auto-compile! #t)', self.g)
run('(define (cube x) (* x x x))', self.g)
path = tempfile.mktemp(suffix='.lspc')
try:
run(f'(save-compiled "{path}" cube)', self.g)
run(f'(define cube2 (load-compiled "{path}"))', self.g)
assert run('(cube2 3)', self.g) == 27
finally:
if os.path.exists(path): os.unlink(path)
run('(auto-compile! #f)', self.g)
###############################################################################
# Main
###############################################################################

View file

@ -37,8 +37,8 @@ class _Nil:
NIL = _Nil()
class Pair:
__slots__ = ('car', 'cdr')
def __init__(self, a, d): self.car = a; self.cdr = d
__slots__ = ('car', 'cdr', '_line')
def __init__(self, a, d): self.car = a; self.cdr = d; self._line = None
def __iter__(self):
n = self
while isinstance(n, Pair): yield n.car; n = n.cdr
@ -188,6 +188,7 @@ class LispErr(Exception):
def __init__(self, msg, obj=None):
super().__init__(msg); self.obj = obj # obj is ErrorObject or None
self.call_stack = list(_call_stack)
self.source_line = None # filled in by VM when source map available
class ErrorObject:
"""R7RS error object — carried by LispErr when raised via (error ...)."""
@ -320,6 +321,16 @@ _TOK_RE = re.compile(r'''
def _tokenize(src):
return [t for t in _TOK_RE.findall(src) if not t.startswith(';')]
def _tokenize_lines(src):
"""Tokenize with line numbers: returns list of (token, line_number) tuples."""
result = []
for m in _TOK_RE.finditer(src):
tok = m.group()
if tok.startswith(';'): continue
line = src.count('\n', 0, m.start()) + 1
result.append((tok, line))
return result
###############################################################################
# Parser
###############################################################################
@ -330,27 +341,36 @@ _QQ = {"'": S('quote'), '`': S('quasiquote'),
def _read(toks, i):
if i >= len(toks): raise LispErr('unexpected EOF')
t = toks[i]; i += 1
# Support both plain tokens and (token, line) tuples
if isinstance(t, tuple): t, line = t
else: line = None
if t in _QQ:
v, i = _read(toks, i)
return Pair(_QQ[t], Pair(v, NIL)), i
p = Pair(_QQ[t], Pair(v, NIL)); p._line = line
return p, i
if t == '(':
items = []; tail = None
items = []; item_lines = []; tail = None
while True:
if i >= len(toks): raise LispErr('unclosed (')
if toks[i] == ')': i += 1; break
if toks[i] == '.':
ti = toks[i]; tiv = ti[0] if isinstance(ti, tuple) else ti
if tiv == ')': i += 1; break
if tiv == '.':
i += 1; tail, i = _read(toks, i)
if i >= len(toks) or toks[i] != ')': raise LispErr('. without )')
ti2 = toks[i] if i < len(toks) else None
tiv2 = ti2[0] if isinstance(ti2, tuple) else ti2
if tiv2 != ')': raise LispErr('. without )')
i += 1; break
v, i = _read(toks, i); items.append(v)
r = NIL if tail is None else tail
for x in reversed(items): r = Pair(x, r)
if isinstance(r, Pair): r._line = line
return r, i
if t == '#(': # vector literal
items = []
while True:
if i >= len(toks): raise LispErr('unclosed #(')
if toks[i] == ')': i += 1; break
ti = toks[i]; tiv = ti[0] if isinstance(ti, tuple) else ti
if tiv == ')': i += 1; break
v, i = _read(toks, i); items.append(v)
return items, i
if t == ')': raise LispErr('unexpected )')
@ -380,8 +400,9 @@ def _atom(t):
except (ValueError, ZeroDivisionError): pass
return S(t)
def read_all(src):
toks = _tokenize(src); exprs = []; i = 0
def read_all(src, track_lines=False):
toks = _tokenize_lines(src) if track_lines else _tokenize(src)
exprs = []; i = 0
while i < len(toks): e, i = _read(toks, i); exprs.append(e)
return exprs
@ -485,6 +506,19 @@ class Env:
if rest is not None: c.b[rest] = _P(args[n:])
return c
def _deep_copy_env(env):
"""Deep-copy env chain up to (but not including) the global env.
Global env (builtins) is shared. Returns a fresh chain for multi-shot continuations."""
if env is None: return None
g = env.g
if env is g: return env # don't copy global env
new = Env.__new__(Env)
new.b = dict(env.b)
new.g = g
new.p = _deep_copy_env(env.p)
return new
###############################################################################
# Quasiquote expander
###############################################################################
@ -1016,12 +1050,13 @@ def leval(expr, env):
def _cont_resume(ci):
"""Resume an escaped continuation."""
"""Resume an escaped continuation (multi-shot safe: deep-copies env)."""
c = ci.cont
frames = [(i, p, e, list(s)) for i, p, e, s in c.frames]
frames = [(i, p, _deep_copy_env(e), list(s)) for i, p, e, s in c.frames]
stack = list(c.stack); stack.append(ci.val)
env = _deep_copy_env(c.env)
try:
return _vm_loop(c.instrs, c.ip, stack, c.env, frames, c.vm_id)
return _vm_loop(c.instrs, c.ip, stack, env, frames, c.vm_id)
except _ContInvoked as ci2:
return _cont_resume(ci2)
@ -1051,7 +1086,7 @@ def _call(proc, args, env):
def _load(path, env):
with open(path) as f:
src = f.read()
for expr in read_all(src): leval(expr, env)
for expr in read_all(src, track_lines=True): leval(expr, env)
###############################################################################
# Bytecode Compiler & VM
@ -1088,6 +1123,15 @@ _BC_SPECIALIZE = {
}
# Constant folding tables
def _bc_is_global(sym, env):
"""Check if sym is not locally shadowed (resolves to global env)."""
e = env
g = e.g
while e is not None and e is not g:
if sym in e.b: return False
e = e.p
return True
def _bc_is_const(expr):
"""Is expr a compile-time constant?"""
if isinstance(expr, (int, float, Fraction)): return True
@ -1119,11 +1163,16 @@ _BC_FOLDABLE = {
class CodeObj:
"""Compiled bytecode chunk."""
__slots__ = ('instrs', 'name')
__slots__ = ('instrs', 'name', 'source_map', 'ic', '_cur_line')
def __init__(self, name=None):
self.instrs = []; self.name = name
self.source_map = [] # parallel to instrs: line number or None
self.ic = None # inline cache (populated at runtime)
self._cur_line = None # current source line during compilation
def emit(self, op, arg=None):
idx = len(self.instrs); self.instrs.append((op, arg)); return idx
idx = len(self.instrs); self.instrs.append((op, arg))
self.source_map.append(self._cur_line)
return idx
def patch(self, addr, arg):
self.instrs[addr] = (self.instrs[addr][0], arg)
@ -1149,6 +1198,9 @@ _BC_FALLBACK = frozenset(map(S, [
def _bc(expr, code, env, tail=False):
"""Compile expr into bytecode instructions in code."""
# Track source line from Pair nodes
if isinstance(expr, Pair) and expr._line is not None:
code._cur_line = expr._line
# Self-evaluating
if expr is VOID: code.emit(OP_VOID); return
if expr is NIL or expr is True or expr is False:
@ -1319,7 +1371,9 @@ def _bc(expr, code, env, tail=False):
for b in binds: _bc(_L(b)[1], code, env)
code.emit(OP_PUSH_ENV)
for b in reversed(binds): code.emit(OP_BIND, _L(b)[0])
_bc_body(body, code, env, tail=tail)
body_env = Env(env)
for b in binds: body_env.define(_L(b)[0], VOID)
_bc_body(body, code, body_env, tail=tail)
if not tail: code.emit(OP_POP_ENV)
return
@ -1327,9 +1381,11 @@ def _bc(expr, code, env, tail=False):
if head is S('let*'):
a = _L(args); binds = _L(a[0]); body = a[1:]
code.emit(OP_PUSH_ENV)
body_env = Env(env)
for b in binds:
bp = _L(b); _bc(bp[1], code, env); code.emit(OP_BIND, bp[0])
_bc_body(body, code, env, tail=tail)
bp = _L(b); _bc(bp[1], code, body_env); code.emit(OP_BIND, bp[0])
body_env.define(bp[0], VOID)
_bc_body(body, code, body_env, tail=tail)
if not tail: code.emit(OP_POP_ENV)
return
@ -1337,11 +1393,12 @@ def _bc(expr, code, env, tail=False):
if head is S('letrec') or head is S('letrec*'):
a = _L(args); binds = _L(a[0]); body = a[1:]
code.emit(OP_PUSH_ENV)
for b in binds: code.emit(OP_VOID); code.emit(OP_BIND, _L(b)[0])
body_env = Env(env)
for b in binds: code.emit(OP_VOID); code.emit(OP_BIND, _L(b)[0]); body_env.define(_L(b)[0], VOID)
for b in binds:
bp = _L(b); _bc(bp[1], code, env)
bp = _L(b); _bc(bp[1], code, body_env)
code.emit(OP_SET, bp[0])
_bc_body(body, code, env, tail=tail)
_bc_body(body, code, body_env, tail=tail)
if not tail: code.emit(OP_POP_ENV)
return
@ -1394,17 +1451,18 @@ def _bc(expr, code, env, tail=False):
except LispErr:
pass
# --- Constant folding ---
# --- Constant folding (only for unshadowed globals) ---
call_args = _L(args)
if isinstance(head, Symbol) and head in _BC_FOLDABLE and all(_bc_is_const(a) for a in call_args):
try:
result = _BC_FOLDABLE[head]([a for a in call_args])
code.emit(OP_CONST, result); return
except Exception:
pass
if _bc_is_global(head, env):
try:
result = _BC_FOLDABLE[head]([a for a in call_args])
code.emit(OP_CONST, result); return
except Exception:
pass
# --- Specialized opcodes for hot builtins ---
if isinstance(head, Symbol):
# --- Specialized opcodes for hot builtins (only unshadowed) ---
if isinstance(head, Symbol) and _bc_is_global(head, env):
n = len(call_args)
spec = _BC_SPECIALIZE.get(head)
if spec and n in spec:
@ -1494,8 +1552,9 @@ def _peephole(code):
mapping[i] = new_idx
if i not in remove: new_idx += 1
mapping[n] = new_idx # for jumps pointing past the end
# Rebuild with adjusted jumps
new_instrs = []
# Rebuild with adjusted jumps and source map
new_instrs = []; new_smap = []
smap = code.source_map
for i in range(n):
if i in remove: continue
op, arg = instrs[i]
@ -1503,7 +1562,9 @@ def _peephole(code):
new_instrs.append((op, mapping.get(arg, arg)))
else:
new_instrs.append((op, arg))
new_smap.append(smap[i] if i < len(smap) else None)
code.instrs = new_instrs
code.source_map = new_smap
def _jump_targets(instrs):
@ -1521,12 +1582,12 @@ class _ContInvoked(Exception):
def __init__(self, cont, val): self.cont = cont; self.val = val
class FullCont:
"""Full continuation (supports upward escape from call/cc)."""
"""Full multi-shot continuation. Snapshots env at capture time."""
__slots__ = ('frames', 'stack', 'ip', 'instrs', 'env', 'vm_id')
def __init__(self, frames, stack, ip, instrs, env, vm_id):
self.frames = [(i, p, e, list(s)) for i, p, e, s in frames]
self.frames = [(i, p, _deep_copy_env(e), list(s)) for i, p, e, s in frames]
self.stack = list(stack); self.ip = ip
self.instrs = instrs; self.env = env; self.vm_id = vm_id
self.instrs = instrs; self.env = _deep_copy_env(env); self.vm_id = vm_id
def __call__(self, args, _env):
raise _ContInvoked(self, args[0] if args else VOID)
def __repr__(self): return '#<continuation>'
@ -1539,27 +1600,46 @@ def vm_exec(code, env):
_vm_depth[0] += 1
try:
instrs = code.instrs; ip = 0; stack = []; frames = []
smap = code.source_map
while True:
try:
return _vm_loop(instrs, ip, stack, env, frames, vm_id)
except _ContInvoked as ci:
if ci.cont.vm_id is not vm_id:
raise # not our continuation, propagate to outer VM
raise
c = ci.cont
frames = [(i, p, e, list(s)) for i, p, e, s in c.frames]
frames = [(i, p, _deep_copy_env(e), list(s)) for i, p, e, s in c.frames]
stack = list(c.stack); stack.append(ci.val)
ip = c.ip; instrs = c.instrs; env = c.env
ip = c.ip; instrs = c.instrs; env = _deep_copy_env(c.env)
smap = None
except LispErr as e:
if e.source_line is None and smap and ip > 0 and ip - 1 < len(smap):
e.source_line = smap[ip - 1]
raise
finally:
_vm_depth[0] -= 1
def _vm_loop(instrs, ip, stack, env, frames, vm_id):
"""Inner VM loop with explicit frame stack."""
"""Inner VM loop with explicit frame stack and inline caching."""
_ap = stack.append; _po = stack.pop
_isinstance = isinstance; _CP = CompiledProc; _Pr = Proc
_ic = {} # inline cache: {instr_idx: (cached_env, cached_val)}
while ip < len(instrs):
op, arg = instrs[ip]; ip += 1
if op == OP_CONST: _ap(arg)
elif op == OP_LOOKUP: _ap(env.lookup(arg))
elif op == OP_LOOKUP:
idx = ip - 1
cached = _ic.get(idx)
if cached is not None and arg not in env.b:
ce, cv = cached
if arg in ce.b:
_ap(ce.b[arg]); continue
val = env.lookup(arg)
# Cache if resolved to global (safe — globals rarely change)
g = env.g
if g is not None and arg in g.b:
_ic[idx] = (g, val)
_ap(val)
elif op == OP_SET: env.set(arg, _po())
elif op == OP_DEFINE: env.define(arg, _po())
elif op == OP_POP: _po()
@ -1667,6 +1747,112 @@ def _vm_loop(instrs, ip, stack, env, frames, vm_id):
return stack[-1] if stack else VOID
###############################################################################
# Bytecode Serialization
###############################################################################
import json as _json
def _serialize_operand(val):
"""Serialize a bytecode operand to a JSON-compatible value."""
if val is None: return None
if val is True: return {'t': 'bool', 'v': True}
if val is False: return {'t': 'bool', 'v': False}
if isinstance(val, int): return val # JSON native
if isinstance(val, float):
if math.isinf(val): return {'t': 'float', 'v': '+inf' if val > 0 else '-inf'}
if math.isnan(val): return {'t': 'float', 'v': 'nan'}
return {'t': 'float', 'v': val}
if isinstance(val, Fraction): return {'t': 'frac', 'n': val.numerator, 'd': val.denominator}
if isinstance(val, Symbol): return {'t': 'sym', 'v': str(val)}
if isinstance(val, MutableString): return {'t': 'str', 'v': str(val)}
if isinstance(val, str): return {'t': 'str', 'v': val}
if val is NIL: return {'t': 'nil'}
if val is VOID: return {'t': 'void'}
if val is EOF: return {'t': 'eof'}
if isinstance(val, Pair): return {'t': 'pair', 'car': _serialize_operand(val.car),
'cdr': _serialize_operand(val.cdr)}
if isinstance(val, list): # vector
return {'t': 'vec', 'v': [_serialize_operand(x) for x in val]}
if isinstance(val, tuple):
# OP_MAKE_CLOSURE: (CodeObj, params, rest)
if len(val) == 3 and isinstance(val[0], CodeObj):
code, params, rest = val
return {'t': 'closure', 'code': _serialize_code(code),
'params': [str(p) for p in params],
'rest': str(rest) if rest else None}
return {'t': 'repr', 'v': repr(val)}
def _deserialize_operand(data):
"""Deserialize a bytecode operand from JSON data."""
if data is None: return None
if isinstance(data, int): return data
if isinstance(data, dict):
t = data.get('t')
if t == 'bool': return data['v']
if t == 'float':
v = data['v']
if v == '+inf': return math.inf
if v == '-inf': return -math.inf
if v == 'nan': return float('nan')
return v
if t == 'frac': return Fraction(data['n'], data['d'])
if t == 'sym': return S(data['v'])
if t == 'str': return data['v']
if t == 'nil': return NIL
if t == 'void': return VOID
if t == 'eof': return EOF
if t == 'pair': return Pair(_deserialize_operand(data['car']),
_deserialize_operand(data['cdr']))
if t == 'vec': return [_deserialize_operand(x) for x in data['v']]
if t == 'closure':
code = _deserialize_code(data['code'])
params = [S(p) for p in data['params']]
rest = S(data['rest']) if data['rest'] else None
return (code, params, rest)
return data
def _serialize_code(code):
"""Serialize a CodeObj to a JSON-compatible dict."""
return {
'name': code.name,
'instrs': [[op, _serialize_operand(arg)] for op, arg in code.instrs],
'source_map': code.source_map,
}
def _deserialize_code(data):
"""Deserialize a CodeObj from a JSON dict."""
code = CodeObj(name=data.get('name'))
code.instrs = [(op, _deserialize_operand(arg)) for op, arg in data['instrs']]
code.source_map = data.get('source_map', [None] * len(code.instrs))
return code
def save_compiled(path, proc):
"""Save a compiled procedure to a .lspc file."""
if not isinstance(proc, CompiledProc):
raise LispErr(f'save-compiled: not a compiled procedure: {show(proc)}')
data = {
'format': 'lspc-v1',
'name': proc.name,
'params': [str(p) for p in proc.params],
'rest': str(proc.rest) if proc.rest else None,
'code': _serialize_code(proc.code),
}
with open(path, 'w') as f:
_json.dump(data, f, separators=(',', ':'))
def load_compiled(path, env):
"""Load a compiled procedure from a .lspc file."""
with open(path) as f:
data = _json.load(f)
if data.get('format') != 'lspc-v1':
raise LispErr(f'load-compiled: unsupported format: {data.get("format")}')
code = _deserialize_code(data['code'])
params = [S(p) for p in data['params']]
rest = S(data['rest']) if data['rest'] else None
return CompiledProc(code, params, rest, env, data.get('name'))
# Auto-compile flag
_auto_compile = [False]
@ -2470,6 +2656,8 @@ def make_global_env():
d(S('compile'), lambda a, e: bc_compile_proc(a[0], e))
d(S('compiled?'), lambda a, _: isinstance(a[0], CompiledProc))
d(S('disassemble'),lambda a, _: (print(_disassemble(a[0])) or VOID))
d(S('save-compiled'), lambda a, _: save_compiled(_str_val(a[0]), a[1]) or VOID)
d(S('load-compiled'), lambda a, e: load_compiled(_str_val(a[0]), e))
def _auto_compile_fn(a, _):
if not a: return _auto_compile[0]
_auto_compile[0] = _truthy(a[0]); return VOID
@ -2645,7 +2833,8 @@ def repl(env, prompt='λ> ', quiet=False):
if result is not VOID:
print(show(result))
except LispErr as e:
print(f'error: {e}', file=sys.stderr)
loc = f' (line {e.source_line})' if e.source_line else ''
print(f'error{loc}: {e}', file=sys.stderr)
if e.call_stack:
print(f' in: {"".join(e.call_stack[-5:])}', file=sys.stderr)
except Exception as e:
@ -2691,6 +2880,24 @@ Features: R7RS core, bytecode compiler, full continuations, macros,
_auto_compile[0] = True
args = [a for a in args if a not in ('--fast', '-f')]
# --compile: precompile a .lsp file to .lspc
if '--compile' in args:
args = [a for a in args if a != '--compile']
if not args:
print('usage: uncommonlisp --compile file.lsp', file=sys.stderr); sys.exit(1)
path = args[0]; out = path.rsplit('.', 1)[0] + '.lspc'
_auto_compile[0] = True
_load(path, g)
compiled = {k: v for k, v in g.b.items() if isinstance(v, CompiledProc)}
data = {'format': 'lspc-v1', 'procs': {
str(k): {'name': v.name, 'params': [str(p) for p in v.params],
'rest': str(v.rest) if v.rest else None,
'code': _serialize_code(v.code)}
for k, v in compiled.items()}}
with open(out, 'w') as f: _json.dump(data, f, separators=(',', ':'))
print(f'compiled {len(compiled)} procedures to {out}')
return
# -e 'expr' mode
if args and args[0] == '-e':
if len(args) < 2: