From f4e37d340e3b1892562c067ead46156271729a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 06:56:36 +0000 Subject: [PATCH] fix(vm): encode Elixir nil to Lua nil instead of the string "nil" `Lua.encode!/2` matched `nil` in its atom-encoding head (`nil` is an atom that is not a boolean) and converted it to the string `"nil"`. Because all Lua strings are truthy, encoded `nil` silently inverted `if not value then` checks and broke `return nil, "reason"` error-tuple patterns, and the round trip `decode!(encode!(nil))` was lossy. Add a dedicated `encode!/2` head that maps Elixir `nil` to Lua `nil`, matching `decode!/2` and the existing handling of `nil` inside tables and function result lists. Fixes #374 --- CHANGELOG.md | 8 ++++++++ lib/lua.ex | 8 ++++++++ test/lua_test.exs | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb25235..84876c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed +- `Lua.encode!/2` now maps Elixir `nil` to Lua `nil` instead of the string + `"nil"`. Previously top-level `nil` fell into the atom-encoding head and + became `"nil"`, which is truthy in Lua — silently inverting `if not value + then ...` checks and breaking `return nil, "reason"` error patterns. The + round trip `decode!(encode!(nil))` is now lossless, matching the existing + behaviour for `nil` inside tables and function result lists (#374). + ## [1.0.0-rc.3] - 2026-06-15 The fourth release candidate for `1.0.0`. It builds on rc.2 with a diff --git a/lib/lua.ex b/lib/lua.ex index eeca80d0..97ae3a3f 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -917,6 +917,14 @@ defmodule Lua do true """ @spec encode!(t(), term()) :: {term(), t()} + # Elixir `nil` maps to Lua `nil`, mirroring `decode!/2` so the round trip is + # lossless. Without this clause `nil` falls into the atom head below and + # encodes to the string `"nil"`, which is truthy in Lua and silently inverts + # `if not value then ...` checks (e.g. `return nil, "not found"` patterns). + def encode!(%__MODULE__{} = lua, nil) do + {nil, lua} + end + def encode!(%__MODULE__{} = lua, value) when is_atom(value) and not is_boolean(value) do {Atom.to_string(value), lua} end diff --git a/test/lua_test.exs b/test/lua_test.exs index bd097db7..03090b10 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -722,6 +722,23 @@ defmodule LuaTest do assert [{1, 1}, {2, 2}] = lua |> Lua.decode!(ref) |> Enum.sort() end + test "it encodes nil to Lua nil rather than the string \"nil\"" do + lua = Lua.new() + + # Top-level nil must map to Lua nil, not the truthy string "nil" + assert {encoded, lua} = Lua.encode!(lua, nil) + assert encoded == nil + refute encoded == "nil" + + # Round trip is lossless and symmetric with decode!/2 + assert nil == Lua.decode!(lua, encoded) + + # The encoded value is falsy inside Lua, so `return nil, "..."` style + # error patterns and `if not value then` checks behave correctly. + lua = Lua.set!(lua, [:value], encoded) + assert {[true], _lua} = Lua.eval!(lua, "return not value") + end + test "it raises for values that cannot be encoded" do error = "Lua runtime error: Failed to encode {:foo, :bar}"