From cdcc7f2e22d2b51f34f6f207d20d36caba6a72a4 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Mon, 18 May 2026 21:13:38 +0200 Subject: [PATCH] feat: expose Crystal procs as Lua global functions Adds `Stack#function(name, proc)` so Crystal callbacks can be called from Lua. Argument and return types are taken from the proc's signature; closures, method pointers, and proc literals all work. Closes #15. --- README.md | 27 +++++++++ examples/crystal_callback.cr | 34 +++++++++++ spec/lua/stack/function_support_spec.cr | 77 +++++++++++++++++++++++++ src/lua/stack.cr | 1 + src/lua/stack/function_support.cr | 58 +++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 examples/crystal_callback.cr create mode 100644 spec/lua/stack/function_support_spec.cr create mode 100644 src/lua/stack/function_support.cr diff --git a/README.md b/README.md index a09804d..29bd469 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,33 @@ p sum.as(Lua::Function).call(3.2, 1) # => 4.2 lua.close ``` +You can also expose Crystal procs to Lua as global functions. Argument and +return types are taken from the proc's signature, so values flow naturally +between the two languages: + +```crystal +lua = Lua.load + +lua.function "add", ->(x : Float64, y : Float64) { x + y } +lua.run "return add(3, 4)" # => 7.0 + +# closures capture local Crystal state +counter = 0 +lua.function "tick", -> { counter += 1; nil } +lua.run "tick(); tick()" +counter # => 2 + +# method pointers work too +def greet(name : String) + "Hi, #{name}" +end + +lua.function "greet", ->greet(String) +lua.run "return greet('Lua')" # => "Hi, Lua" + +lua.close +``` + More features coming soon. Try it, that's fun :) ## Contributing diff --git a/examples/crystal_callback.cr b/examples/crystal_callback.cr new file mode 100644 index 0000000..3c51867 --- /dev/null +++ b/examples/crystal_callback.cr @@ -0,0 +1,34 @@ +require "../src/lua" + +# crystal run examples/crystal_callback.cr + +lua = Lua.load + +# 1. inline proc literal +lua.function "add", ->(x : Float64, y : Float64) { x + y } +puts lua.run "return add(3, 4)" # => 7.0 + +# 2. closure capturing local Crystal state +counter = 0 +lua.function "tick", -> { counter += 1; nil } +lua.run "tick(); tick(); tick()" +puts "counter = #{counter}" # => counter = 3 + +# 3. method pointer +def greet(name : String) + "Hi, #{name}" +end + +lua.function "greet", ->greet(String) +puts lua.run "return greet('Lua')" # => Hi, Lua + +# 4. mixed types, used inside a larger Lua expression +lua.function "join", ->(prefix : String, count : Int32, ok : Bool) do + "#{prefix}:#{count}:#{ok}" +end +puts lua.run %q{ + local label = join("requests", 5, true) + return label .. " (" .. tostring(add(10, 20)) .. ")" +} # => requests:5:true (30.0) + +lua.close diff --git a/spec/lua/stack/function_support_spec.cr b/spec/lua/stack/function_support_spec.cr new file mode 100644 index 0000000..d58b0a2 --- /dev/null +++ b/spec/lua/stack/function_support_spec.cr @@ -0,0 +1,77 @@ +require "../../spec_helper" + +private def greet(name : String) + "Hi, #{name}" +end + +module Lua::StackMixin + describe FunctionSupport do + describe "#function" do + it "registers a proc taking no arguments" do + s = Stack.new + s.function "answer", -> { 42.0 } + s.run("return answer()").should eq 42.0 + end + + it "registers a proc with typed numeric arguments" do + s = Stack.new + s.function "add", ->(x : Float64, y : Float64) { x + y } + s.run("return add(3, 4)").should eq 7.0 + end + + it "converts integer-typed arguments" do + s = Stack.new + s.function "mul", ->(x : Int32, y : Int32) { x * y } + s.run("return mul(6, 7)").should eq 42.0 + end + + it "accepts string arguments and returns a string" do + s = Stack.new + s.function "shout", ->(text : String) { text.upcase } + s.run("return shout('hello')").should eq "HELLO" + end + + it "captures local Crystal state via closure" do + s = Stack.new + counter = 0 + s.function "tick", -> { counter += 1; nil } + s.run "tick(); tick(); tick()" + counter.should eq 3 + end + + it "registers a method pointer" do + s = Stack.new + s.function "greet", ->greet(String) + s.run("return greet('Lua')").should eq "Hi, Lua" + end + + it "returns no Lua value when the proc returns Nil" do + s = Stack.new + s.function "noop", -> { nil } + s.run("return noop()").should be_nil + end + + it "is callable inside larger Lua expressions" do + s = Stack.new + s.function "double", ->(x : Float64) { x * 2 } + s.run("return 100 * double(3)").should eq 600.0 + end + + it "raises when Lua passes fewer arguments than expected" do + s = Stack.new + s.function "add", ->(x : Float64, y : Float64) { x + y } + expect_raises(ArgumentError, /add.*expects 2/) do + s.run "return add(1)" + end + end + + it "supports several mixed-type arguments" do + s = Stack.new + s.function "join", ->(prefix : String, count : Int32, ok : Bool) do + "#{prefix}:#{count}:#{ok}" + end + s.run("return join('n', 5, true)").should eq "n:5:true" + end + end + end +end diff --git a/src/lua/stack.cr b/src/lua/stack.cr index 31e4380..4058783 100644 --- a/src/lua/stack.cr +++ b/src/lua/stack.cr @@ -11,6 +11,7 @@ module Lua include StackMixin::CoroutineSupport include StackMixin::StandardLibraries include StackMixin::ClassSupport + include StackMixin::FunctionSupport getter state getter libs = Set(Symbol).new diff --git a/src/lua/stack/function_support.cr b/src/lua/stack/function_support.cr new file mode 100644 index 0000000..ba8815d --- /dev/null +++ b/src/lua/stack/function_support.cr @@ -0,0 +1,58 @@ +module Lua + module StackMixin::FunctionSupport + # Registers a Crystal `Proc` as a global Lua function under the given name. + # + # Argument types come from the proc's signature: each Lua argument is + # converted to the matching Crystal type before the proc is called, and + # the proc's return value is pushed back onto the Lua stack. A proc + # returning `Nil` produces no Lua return value. + # + # ``` + # lua = Lua.load + # + # # inline proc literal + # lua.function "add", ->(x : Float64, y : Float64) { x + y } + # lua.run "return add(3, 4)" # => 7.0 + # + # # closure over local state + # counter = 0 + # lua.function "tick", -> { counter += 1; nil } + # lua.run "tick(); tick()" + # counter # => 2 + # + # # method pointer + # def greet(name : String) + # "Hi, #{name}" + # end + # + # lua.function "greet", ->greet(String) + # lua.run "return greet('Lua')" # => "Hi, Lua" + # ``` + def function(name : String, proc : Proc(*Args, R)) forall Args, R + libs_snapshot = libs.to_a + arity = {{ Args.type_vars.size }} + wrapper = ->(state : LibLua::State) do + if LibLua.gettop(state) < arity + raise ArgumentError.new("'#{name}' expects #{arity} argument(s)") + end + stack = Lua::Stack.new(state, libs_snapshot) + {% if Args.type_vars.empty? %} + result = proc.call + {% else %} + result = proc.call( + {% for type, i in Args.type_vars %} + ::LuaCallable::LuaConvert({{type}}).convert(stack[{{i + 1}}]), + {% end %} + ) + {% end %} + {% if R.resolve == Nil %} + 0 + {% else %} + stack << result + 1 + {% end %} + end + set_global(name, wrapper) + end + end +end