diff --git a/lua.carp b/lua.carp index ad9d44a..f33734b 100644 --- a/lua.carp +++ b/lua.carp @@ -124,6 +124,8 @@ (doc GC_ERROR "Status code returned when the garbage collector metamethod fails.") (register GC_ERROR Int "LUA_ERRGCMM") + (doc YIELD "Status code returned when a coroutine yields.") + (register YIELD Int "LUA_YIELD") (doc setup "Set up Lua includes and linking. `location` is the include path (e.g. `\"lua\"` or `\"lua5.4\"`). An optional second argument overrides the @@ -225,6 +227,8 @@ Use [`Luax.do-in`](#do-in) for a version that returns `Result`.") (register TYPE_USERDATA Int "LUA_TUSERDATA") (doc TYPE_LIGHTUSERDATA "Type constant for light userdata values.") (register TYPE_LIGHTUSERDATA Int "LUA_TLIGHTUSERDATA") + (doc TYPE_THREAD "Type constant for thread (coroutine) values.") + (register TYPE_THREAD Int "LUA_TTHREAD") (doc type-of "Return the type constant of the value at `index`. Compare against `TYPE_NIL`, `TYPE_NUMBER`, etc.") @@ -323,6 +327,47 @@ metatable `name` from the registry. Raises a Lua error otherwise (catchable with pointer instead of raising a Lua error when the check fails.") (register test-userdata (Fn [&Lua Int (Ptr CChar)] (Ptr ())) "luaL_testudata") + ; === Coroutines === + + (doc new-thread "Create a new coroutine as a thread of the given state. Pushes +the new thread onto the stack and returns a pointer to it. The coroutine shares +the global environment with the parent but has its own execution stack. Do not +call [`close`](#close) on the returned thread—it is garbage-collected by the +parent state.") + (register new-thread (Fn [&Lua] &Lua) "lua_newthread") + (doc coroutine-status "Return the status of a coroutine: [`OK`](#OK) if the +coroutine has not started or finished successfully, [`YIELD`](#YIELD) if it is +suspended, or an error code if it terminated with an error.") + (register coroutine-status (Fn [&Lua] Int) "lua_status") + (doc is-yieldable? "Return `true` if the given coroutine can yield.") + (deftemplate is-yieldable? + (Fn [&Lua] Bool) + "bool $NAME(lua_State* l)" + "$DECL { return lua_isyieldable(l); }") + (doc resume "Resume a suspended coroutine. Before the first resume, push the +function to call onto the coroutine’s stack; on subsequent resumes, push values +to return from [`yield`](#yield). `nargs` is the number of values pushed. +`from` is the calling state. Returns [`OK`](#OK) when the coroutine finishes, +[`YIELD`](#YIELD) when it suspends, or an error code. Results are left on the +coroutine’s stack.") + (deftemplate resume + (Fn [&Lua &Lua Int] Int) + "int $NAME(lua_State* co, lua_State* from, int nargs)" + "$DECL { int nres; return lua_resume(co, from, nargs, &nres); }") + (doc yield "Yield the current coroutine with `nresults` values from the top of +the stack. Can only be used as the return expression of a C function registered +with [`prepare-cfunction`](#prepare-cfunction).") + (deftemplate yield + (Fn [&Lua Int] Int) + "int $NAME(lua_State* l, int n)" + "$DECL { return lua_yield(l, n); }") + (doc to-thread "Read the value at `index` as a `Lua` state pointer. Returns a +null pointer if the value is not a thread.") + (deftemplate to-thread + (Fn [&Lua Int] &Lua) + "lua_State* $NAME(lua_State* l, int i)" + "$DECL { return lua_tothread(l, i); }") + (doc do-file "Load and execute a Lua file. Returns a status code. Use [`eval-file`](#eval-file) for a version that returns `Result`.") (deftemplate do-file @@ -587,6 +632,29 @@ Full userdata ([`new-userdata`](#new-userdata)) allocates GC-managed memory on the Lua side, useful for exposing Carp-created objects to Lua scripts with metatables for method dispatch. +Coroutines are created with [`new-thread`](#new-thread), which returns a new +execution context sharing the parent’s globals. Push a function onto the +coroutine’s stack, then drive it with [`resume`](#resume). The coroutine +yields values back via `coroutine.yield()` (Lua side) or [`yield`](#yield) +(C function side, tail-call only). Check [`coroutine-status`](#coroutine-status) +or the return value of `resume` ([`OK`](#OK) vs [`YIELD`](#YIELD)) to +distinguish completion from suspension. + +``` +(Lua.with-lua-do + (Lua.libs lua) + (ignore (Lua.do-string lua + (cstr \"function gen() coroutine.yield(1); return 2 end\"))) + (let [co (Lua.new-thread lua)] + (do + (Lua.get-global co (cstr \"gen\")) + (ignore (Lua.resume co lua 0)) ; yields 1 + (IO.println &(str (Lua.get-int co -1))) + (Lua.pop co 1) + (ignore (Lua.resume co lua 0)) ; returns 2 + (IO.println &(str (Lua.get-int co -1)))))) +``` + The module also provides convenience macros: [`fun`](#fun) defines a Lua function from inline source, [`val`](#val) evaluates a Lua expression into a global, and [`register-fn`](#register-fn) registers a Carp function as a @@ -684,6 +752,22 @@ and assigns it to a global in one expression: (match (Luax.do-in lua \"x = 1 + nil\") (Result.Success _) () (Result.Error e) (IO.println &(fmt \"caught: %s\" &e))) +``` + +**Coroutines.** [`resume-coroutine`](#resume-coroutine) wraps +[`Lua.resume`](#resume) with `Result` error handling, and +[`coroutine-suspended?`](#coroutine-suspended?) / +[`coroutine-finished?`](#coroutine-finished?) provide status predicates: + +``` +(let [co (Lua.new-thread lua)] + (do + (Lua.get-global co (cstr \"gen\")) + (match (Luax.resume-coroutine co lua 0) + (Result.Success status) + (when (= status Lua.YIELD) + (IO.println &(str (Lua.get-int co -1)))) + (Result.Error e) (IO.errorln &e)))) ```") (defmodule Luax @@ -810,4 +894,28 @@ state argument is inserted into the push expression automatically. `(do (Lua.create-table %lua 0 %n) %@stmts - (Lua.set-global %lua (cstr %(Symbol.str name))))))) + (Lua.set-global %lua (cstr %(Symbol.str name)))))) + + ; === Coroutine helpers === + + (doc coroutine-suspended? + "Return `true` if the coroutine is suspended (has yielded).") + (defn coroutine-suspended? [co] (= (Lua.coroutine-status co) Lua.YIELD)) + + (doc coroutine-finished? + "Return `true` if the coroutine has finished or has not yet started.") + (defn coroutine-finished? [co] (= (Lua.coroutine-status co) Lua.OK)) + + (doc resume-coroutine + "Resume the coroutine `co` from state `from` with `nargs` +arguments already pushed onto `co`’s stack. Returns `(Success status)` where +`status` is [`OK`](#OK) (coroutine finished) or [`YIELD`](#YIELD) (coroutine +suspended), or `(Error msg)` if the coroutine raised an error. Results or +yielded values are left on `co`’s stack.") + (sig resume-coroutine (Fn [&Lua &Lua Int] (Result Int String))) + (defn resume-coroutine [co from nargs] + (let [status (Lua.resume co from nargs)] + (if (or (= status Lua.OK) (= status Lua.YIELD)) + (Result.Success status) + (Result.Error + (String.from-cstr-or (Lua.to-string co -1) @"coroutine error")))))) diff --git a/test/coroutine.carp b/test/coroutine.carp new file mode 100644 index 0000000..e7e629f --- /dev/null +++ b/test/coroutine.carp @@ -0,0 +1,331 @@ +(load "../lua.carp") +(load "Test.carp") +(use Test) + +(add-cflag "-I/opt/homebrew/include") +(add-cflag "-L/opt/homebrew/lib") +(Lua.setup "lua") + +; === C function that yields a value (tail-call yield) === + +(defn yield-value [l] + (let-do [x (Lua.get-int l 1)] (Lua.push-int l (* x 10)) (Lua.yield l 1))) + +(Lua.prepare-cfunction yield-value) + +; === C function that returns without yielding === + +(defn no-yield [l] (do (Lua.push-int l 42) 1)) + +(Lua.prepare-cfunction no-yield) + +(deftest test + ; === YIELD constant === + (assert-true test (/= Lua.OK Lua.YIELD) "YIELD is distinct from OK") + + ; === TYPE_THREAD constant === + (assert-equal test + Lua.TYPE_THREAD + (Lua.with-lua-do (ignore (Lua.new-thread lua)) (Lua.type-of lua -1)) + "new-thread pushes a thread value") + + ; === new-thread === + (assert-equal test + 1 + (Lua.with-lua-do (ignore (Lua.new-thread lua)) (Lua.get-top lua)) + "new-thread pushes exactly one value onto the parent stack") + + ; === coroutine-status on fresh thread === + (assert-equal test + Lua.OK + (Lua.with-lua-do (let [co (Lua.new-thread lua)] (Lua.coroutine-status co))) + "fresh coroutine has status OK") + + ; === to-thread === + (assert-equal test + Lua.TYPE_THREAD + (Lua.with-lua-do + (let-do [co (Lua.new-thread lua)] + (Lua.push-int co 42) + (Lua.type-of lua -1))) + "to-thread: thread on parent stack is accessible") + + ; === Lua-defined coroutine: basic resume/yield === + (assert-equal test + 1 + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function counter() coroutine.yield(1); coroutine.yield(2); return 3 end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "counter")) + (ignore (Lua.resume co lua 0)) + (Lua.get-int co -1))) + "first yield produces 1") + + (assert-equal test + 2 + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function counter() coroutine.yield(1); coroutine.yield(2); return 3 end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "counter")) + (ignore (Lua.resume co lua 0)) + (Lua.pop co 1) + (ignore (Lua.resume co lua 0)) + (Lua.get-int co -1))) + "second yield produces 2") + + (assert-equal test + 3 + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function counter() coroutine.yield(1); coroutine.yield(2); return 3 end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "counter")) + (ignore (Lua.resume co lua 0)) + (Lua.pop co 1) + (ignore (Lua.resume co lua 0)) + (Lua.pop co 1) + (ignore (Lua.resume co lua 0)) + (Lua.get-int co -1))) + "final return produces 3") + + ; === resume status codes === + (assert-equal test + Lua.YIELD + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function yielder() coroutine.yield() end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "yielder")) + (Lua.resume co lua 0))) + "resume returns YIELD when coroutine suspends") + + (assert-equal test + Lua.OK + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function yielder() coroutine.yield() end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "yielder")) + (ignore (Lua.resume co lua 0)) + (Lua.resume co lua 0))) + "resume returns OK when coroutine finishes") + + ; === coroutine-status reflects yield/finish === + (assert-true test + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function yielder() coroutine.yield() end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "yielder")) + (ignore (Lua.resume co lua 0)) + (Luax.coroutine-suspended? co))) + "coroutine-suspended? returns true after yield") + + (assert-true test + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function yielder() coroutine.yield() end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "yielder")) + (ignore (Lua.resume co lua 0)) + (ignore (Lua.resume co lua 0)) + (Luax.coroutine-finished? co))) + "coroutine-finished? returns true after coroutine completes") + + ; === passing values into resume === + (assert-equal test + 42 + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function echo() local x = coroutine.yield(); return x end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "echo")) + (ignore (Lua.resume co lua 0)) + (Lua.pop co (Lua.get-top co)) + (Lua.push-int co 42) + (ignore (Lua.resume co lua 1)) + (Lua.get-int co -1))) + "values pushed before resume are received by yield") + + ; === coroutine error handling === + (assert-true test + (/= Lua.OK + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr "function bad_co() error('oops') end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "bad_co")) + (Lua.resume co lua 0)))) + "resume returns error code when coroutine errors") + + ; === C function coroutine with tail-call yield === + (assert-equal test + Lua.YIELD + (Lua.with-lua-do (Lua.libs lua) + (let-do [co (Lua.new-thread lua)] + (Lua.push-cfunction co yield-value) + (Lua.push-int co 5) + (Lua.resume co lua 1))) + "C function coroutine: resume returns YIELD") + + (assert-equal test + 50 + (Lua.with-lua-do (Lua.libs lua) + (let-do [co (Lua.new-thread lua)] + (Lua.push-cfunction co yield-value) + (Lua.push-int co 5) + (ignore (Lua.resume co lua 1)) + (Lua.get-int co -1))) + "C function coroutine: yielded value is correct") + + ; === C function coroutine that returns without yielding === + (assert-equal test + Lua.OK + (Lua.with-lua-do (Lua.libs lua) + (let-do [co (Lua.new-thread lua)] + (Lua.push-cfunction co no-yield) + (Lua.resume co lua 0))) + "C function coroutine: returns OK when function does not yield") + + (assert-equal test + 42 + (Lua.with-lua-do (Lua.libs lua) + (let-do [co (Lua.new-thread lua)] + (Lua.push-cfunction co no-yield) + (ignore (Lua.resume co lua 0)) + (Lua.get-int co -1))) + "C function coroutine: return value accessible after resume") + + ; === Luax.resume-coroutine === + (assert-true test + (= &(Result.Success Lua.YIELD) + &(Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function yielder() coroutine.yield(1) end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "yielder")) + (Luax.resume-coroutine co lua 0)))) + "resume-coroutine returns Success YIELD on suspend") + + (assert-true test + (= &(Result.Success Lua.OK) + &(Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function yielder() coroutine.yield(1) end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "yielder")) + (ignore (Luax.resume-coroutine co lua 0)) + (Lua.pop co 1) + (Luax.resume-coroutine co lua 0)))) + "resume-coroutine returns Success OK on finish") + + (assert-true test + (Result.error? + &(Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr "function bad() error('boom') end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "bad")) + (Luax.resume-coroutine co lua 0)))) + "resume-coroutine returns Error on coroutine error") + + ; === yields with string values === + (assert-true test + (= @"a" + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function letters() coroutine.yield('a'); coroutine.yield('b'); return 'c' end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "letters")) + (ignore (Lua.resume co lua 0)) + (String.from-cstr-or (Lua.to-string co -1) @"")))) + "coroutine yields string value on first resume") + (assert-true test + (= @"c" + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function letters() coroutine.yield('a'); coroutine.yield('b'); return 'c' end"))) + (let-do [co (Lua.new-thread lua)] + (Lua.get-global co (cstr "letters")) + (ignore (Lua.resume co lua 0)) + (Lua.pop co 1) + (ignore (Lua.resume co lua 0)) + (Lua.pop co 1) + (ignore (Lua.resume co lua 0)) + (String.from-cstr-or (Lua.to-string co -1) @"")))) + "coroutine returns string value on final resume") + + ; === multiple coroutines === + (assert-equal test + 2 + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function inc(start) local i = start; while true do coroutine.yield(i); i = i + 1 end end"))) + (let-do [co1 (Lua.new-thread lua) + co2 (Lua.new-thread lua)] + (Lua.get-global co1 (cstr "inc")) + (Lua.push-int co1 1) + (Lua.get-global co2 (cstr "inc")) + (Lua.push-int co2 100) + ; first resume: co1 yields 1, co2 yields 100 + (ignore (Lua.resume co1 lua 1)) + (ignore (Lua.resume co2 lua 1)) + (Lua.pop co1 1) + (Lua.pop co2 1) + ; second resume: co1 yields 2, co2 yields 101 + (ignore (Lua.resume co1 lua 0)) + (ignore (Lua.resume co2 lua 0)) + (Lua.get-int co1 -1))) + "two independent coroutines maintain separate state") + + (assert-equal test + 101 + (Lua.with-lua-do (Lua.libs lua) + (ignore + (Lua.do-string lua + (cstr + "function inc(start) local i = start; while true do coroutine.yield(i); i = i + 1 end end"))) + (let-do [co1 (Lua.new-thread lua) + co2 (Lua.new-thread lua)] + (Lua.get-global co1 (cstr "inc")) + (Lua.push-int co1 1) + (Lua.get-global co2 (cstr "inc")) + (Lua.push-int co2 100) + (ignore (Lua.resume co1 lua 1)) + (ignore (Lua.resume co2 lua 1)) + (Lua.pop co1 1) + (Lua.pop co2 1) + (ignore (Lua.resume co1 lua 0)) + (ignore (Lua.resume co2 lua 0)) + (Lua.get-int co2 -1))) + "second coroutine counts independently from first"))