Skip to content

Replace hand-rolled Lua/JSON converters with a serde bridge - #108

Merged
an0nn30 merged 10 commits into
mainfrom
fix/lua-serde-data-bridge
Sep 3, 2026
Merged

Replace hand-rolled Lua/JSON converters with a serde bridge#108
an0nn30 merged 10 commits into
mainfrom
fix/lua-serde-data-bridge

Conversation

@an0nn30

@an0nn30 an0nn30 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

What

Replaces four hand-rolled Lua↔JSON converters in termlab_plugin with a single serde-backed bridge (crates/termlab_plugin/src/lua/convert.rs).

The one that mattered: plugin events reached Lua by building Lua source text from JSON and load()ing it. Object keys escaped " but not \, so a key ending in a backslash produced a malformed literal — the eval failed and the plugin silently received a raw JSON string where it expected a table. bus_event_key_with_backslash_is_delivered_intact is a regression test against exactly that.

The other three:

  • lua_value_to_json (Lua→JSON) — used by app.publish, register_settings_section, query_plugin, ui.open_docked_view, form dialogs
  • json_to_lua_table / json_to_lua_value (JSON→Lua) — docked-view and form dialog responses
  • set_lua_table_from_json_map — merged host responses into a Lua table copying only scalars, so nested objects and arrays were silently dropped from session.current() and session.exec_active()

That fourth one wasn't in the original design; it turned up during planning.

Behavior changes (all deliberate, all documented)

Before After
A function in a payload serialized as null Raises a Lua error
A cyclic table recursed until the stack overflowed Raises a Lua error
Event conversion failure delivered a raw JSON string Logs and drops the event
Non-object dialog/docked-view response became {} Yields nil
Nested values in session responses were dropped Preserved

Every in-tree plugin and example was traced against these: all pass plain data with no functions or cycles, and the docked-view example already guards with if type(result) ~= "table".

Conventions, verified not assumed

The serde options are pinned in one place. serialize_unit_to_null(false) and serialize_none_to_null(false) are load-bearing — mlua's default maps JSON null to a lightuserdata sentinel that compares unequal to nil, which would have quietly broken every if x == nil in plugin code. These behaviors were confirmed with a characterization spike against mlua 0.10 before the code was written, and each has a test.

Test plan

cargo test --workspace

termlab_plugin goes 77 → 93 tests. Includes call-site tests driving session.current() and ui.form() through a mock HostApi, not just the bridge underneath them.

Notes for review

  • Sibling of Sanitize plugin-supplied HTML and validate icon names #107, not a descendant. This branch adds a panel_html doc row saying "Raw HTML rendered in a Shadow DOM" — accurate here, stale next to the sanitization section Sanitize plugin-supplied HTML and validate icon names #107 adds. Whoever merges second should reconcile the wording.
  • handle_query still hands on_query a hand-serialized JSON string and discards a malformed reply silently. Out of scope here; it's the last hand-rolled JSON edge in the Lua runtime.
  • Only the five rustfmt hunks this branch introduced were fixed. The repo has ~187 pre-existing formatting diffs across 40 files; mass-reformatting would have made this diff unreviewable.

🤖 Generated with Claude Code

Single module with pinned serialize and deserialize options, replacing
what will be four hand-rolled converters. Options are fixed here rather
than per call site so payloads round-trip identically regardless of which
API carried them.
Events reached Lua by building Lua source and load()ing it. Object keys
escaped quotes but not backslashes, so a key ending in a backslash produced
a malformed literal: the eval failed and the plugin silently received a raw
JSON string instead of a table.

Drop the string fallback with the codegen. A conversion failure now logs and
drops the event rather than handing plugins an unexpected type.
Replaces lua_value_to_json across publish, settings sections, plugin
queries, docked views, and form dialogs. Functions and cyclic tables now
raise instead of silently serializing as null or recursing until the stack
overflows.
Deletes the last two hand-rolled JSON to Lua converters. Non-object
responses now reach plugins as nil rather than an empty table.
The previous helper copied only scalars, so nested objects and arrays in
host responses were silently dropped before reaching plugins.
Review found the session merge test only exercised merge_json_into_table
directly, missing the JSON parsing, match guard, and post-merge default-fill
at the session.current()/exec_active() call sites, and that the "defaults
survive" assertion never covered a colliding key. Add a call-site test
driving session.current() through a mock HostApi, and a colliding-key
assertion to the existing merge test. Also narrow the plugin-sdk.md note to
name session.current() and session.exec_active() explicitly rather than
reading as if it applied to the whole session table.
Verified no hand-rolled converters remain (grep for
lua_value_to_json/json_to_lua_table/json_to_lua_value/json_to_lua_literal/
json_value_to_lua_literal/set_lua_table_from_json_map returns nothing) and
that SerializeOptions/DeserializeOptions are defined only in
lua/convert.rs. Cross-checked every app.*, ui.*, session.*, and net.*
function registered in lua/api/*.rs against docs/plugin-sdk.md in both
directions: all are documented, and two drift items surfaced:

- ui.panel_html was implemented and listed in the Lua Signatures block but
  missing from the Panel Widget Functions reference table; added it.
- app.query_plugin's conversion note said "publishing" (copy-pasted from
  app.publish above it) when query_plugin sends an RPC call, not a bus
  publish; reworded.

Also added a Lua->JSON test for float preservation in convert.rs. The
replacement suite covered float/integer fidelity only for JSON->Lua
(number_kinds_are_preserved); the deleted lua_value_to_json_primitives
test was the only Lua->JSON float assertion, and nothing replaced it.
Add a real call-site test for ui.form's nil-on-non-object-response
behavior via a mock HostApi, and a test covering
dispatch_event_json_raw's malformed-JSON drop path — both were
previously untested despite being reachable from frontend/plugin
input. Delete two ui.rs tests that duplicated convert.rs's own
coverage while claiming call-site names. Document the raises-a-Lua-
error-on-functions-or-cycles behavior for register_settings_section,
open_docked_view, and form, which shared the same conversion bridge
as publish/query_plugin but weren't mentioned in the docs. Drop a
redundant convert import in ui.rs in favor of the module-qualified
call style already used nearby. Hand-fix the five rustfmt hunks this
branch introduced, without touching the package's pre-existing
formatting debt.
Brings in the CI pipeline fixes and the markdown preview work. No
conflicts: this branch only touches termlab_plugin's Lua/JSON conversion
and docs/plugin-sdk.md, neither of which main changed.

Full workspace suite green after the merge, including the 93
termlab_plugin tests covering the new bridge.
Brings in the plugin HTML sanitizer from #107. No conflicts: that branch's
docs/plugin-sdk.md edits cover the html widget's sanitization allowlist,
while this branch's cover payload conversion behavior for app.publish,
app.query_plugin, app.register_settings_section, ui.form,
ui.open_docked_view, session.current and session.exec_active. Different
sections, no contradiction.

Full workspace suite green after the merge, including the 93
termlab_plugin tests covering the new bridge.
@an0nn30
an0nn30 merged commit b98c980 into main Sep 3, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant