pmacs/docs/lua-bindings-split-framing.md

14 KiB
Raw Permalink Blame History

Splitting lua_bindings.rs — framing (F-016)

src/lua_bindings.rs is 15,202 lines — the repository's largest file by 2×. It concentrates the entire Rust↔Lua surface: a shared core (the registry alias, BindingError, the BufferIdLua userdata, intercept views, ~15 state-holder types, the install() spine) followed by ~20 independent pmacs.<domain> API surfaces (packages, lsp, mcp, completion, project, index, window, minibuffer, parse, theme, process, diag, async, ansi, …) and ~2,800 lines of tests. Audit F-016.

This is exactly the file the audit calls out ("combines many Lua API domains, package management glue, LSP stores, theme bindings, attachment bindings, and tests in one file") and its guidance is explicit: "Split only along stable boundaries, not as a drive-by refactor." The pmacs.<domain> sections, already fenced by // --- banners and each wired through its own install_<domain>_module seam, are those stable boundaries.

Non-negotiable: behavior-preserving code motion

This arc moves code; it does not change it. Every tranche is:

  • Pure relocation. A domain section's items move verbatim into lua_bindings/<domain>.rs. No logic edits, no signature changes, no renames beyond what visibility requires.
  • Minimal visibility widening. Items in the shared core that a moved domain references become pub(crate) (or pub(super)); a domain's own install_<domain>_module becomes pub(super) so the parent install() still calls it. Nothing gains wider visibility than the move demands — the compiler names each one (E0603/E0433), so the set is exact, not guessed.
  • Green per tranche. cargo build + cargo clippy --all-targets + the full test suite pass under both Lua flavors after every tranche, before it's committed. A split that changes a test outcome is a bug in the split.

The Lua-visible API (pmacs.buffer.*, pmacs.lsp.*, …) is byte-for-byte unchanged — the same install() builds the same tables; only the Rust file layout moves. No .lua code and no test behavior changes.

Target structure

src/lua_bindings.rssrc/lua_bindings/:

  • mod.rs — the shared core (registry alias, BindingError, BufferIdLua + its method-adders, LuaInterceptView, the state-holder types, require_init_phase, shared helpers) and the top-level pub fn install() spine that calls each domain's installer. This is the stable hub every domain depends on; it stays put.
  • lua_bindings/<domain>.rs — one file per pmacs.<domain> surface, exposing pub(super) fn install_<domain>_module(...). Each #[cfg(test)] mod tests moves with its domain where the tests only touch that domain's (now pub(super)-reachable) surface; shared/cross-cutting tests stay in mod.rs.

The dependency shape is a hub-and-spoke: mod.rs is the hub; domains are spokes that depend on the hub and (ideally) not on each other. Where a genuine domain→domain edge exists (e.g. completion→lsp), the depended-on domain is extracted first and its needed items widened to pub(crate).

Incremental, not a big bang

A single 15k-line move is unreviewable and risks silent breakage. Instead, each tranche is its own PR: convert the file to a directory module + extract one dependency-ordered group of domains, prove green, merge, next. Leaf domains (no outgoing cross-domain edges) go first to establish the pattern; coupled domains follow their dependencies.

What the coupling recon established

  • Two misplaced helper clusters cause almost every cross-domain edge. The generic JSON converters (lua_to_json/lua_table_to_json/ json_to_lua) sit inside the lsp section but are used by async, mcp, completion, and the completion framework — they belong in shared core. The ANSI converters (event_to_lua_table/style_to_lua_table) sit in the packages line-range but belong to ansi (process uses them too). Hoisting these dissolves the async→lsp, mcp→lsp, completion→lsp, and ansi→packages edges.
  • The banners are not clean cut-lines. The packages line-range is a grab-bag: it physically contains the core module installers (command, menu, help, hook, describe, keymap) that install() calls and that belong to shared core, plus editor sub-installers. Extraction must move items to their logical home, not by line-range.
  • Rust privacy is on our side. A child module can read its ancestors' private items, so a domain moved to lua_bindings/<d>.rs reaches shared-core internals via super:: with no widening. Widening is needed only for (a) a domain's install fn so the parent can call it (pub(super)), (b) sibling→sibling edges, and (c) external callers — the last handled by pub(crate) use <d>::<item>; re-exports in mod.rs so crate::lua_bindings::<item> paths in editor.rs/lua.rs/etc. never change.
  • Real edges that survive the hoist: process → ansi, project → lsp, completion_framework → index/lsp (type-alias only). These extract after their dependency.
  • Tests are one flat #[cfg(test)] mod tests over super::* touching private items (BufferIdLua, require_init_phase, install, handles). They stay in mod.rs and move to lua_bindings/tests.rs last, once the handful of items they name are reachable.

Tranche plan (one PR each)

  1. This PR — directory conversion + the truest leaf (diag). lua_bindings.rslua_bindings/mod.rs; extract the smallest zero-outgoing-edge leaf, diag, as lua_bindings/diag.rs. Deliberately minimal: a large mechanical refactor's first PR should validate the directory module, the super::-access discipline, and the new-file CI path on the simplest real case before moving code in bulk. Subsequent tranches batch multiple domains now that the mechanics are proven.
  2. index (the one genuinely clean remaining leaf). Zero shared-core coupling; establishes the pub(crate) use re-export for external callers (editor.rs, completion_framework.rs) and super:: access to a stranded helper (lua_to_json). Correction after tranche 1: the recon under-counted the misplaced helpers. Beyond the JSON/ANSI clusters, the style/color converters (lua_to_style/style_to_lua/ color_to_lua/…) sit in the theme section, and caller_source + the command/menu builders (build_command_from_spec, build_menu_item_from_spec, BindArgs, register, …) sit at the tail of the minibuffer range — all shared, used across sections. So parse/theme, window, and minibuffer are not clean line-range extractions until the hoist below runs.
  3. Extract mcp — and the corrected mental model. A helper-hoist is not a prerequisite for most domains. The contamination that stopped parse/theme is narrow: it bites only when a shared helper is defined inside the range being extracted (the style helpers live in the theme range and are used elsewhere, so extracting that range removes them from mod.rs). A domain that merely uses a cross-section helper reaches it via super:: (parent-private access) — no hoist needed. So mcp extracts cleanly today: all its items are self-contained, and it reaches the JSON converters (still in the lsp section) via super::json_to_lua/lua_to_json, exactly like index. The JSON-helper hoist is deferred to the tranche that extracts lsp itself (where they are defined).
  4. Hoist-then-extract the genuinely contaminated sections. Only the sections that define shared helpers used elsewhere need a hoist first: theme (style/color converters), minibuffer/window (caller_ source, command/menu builders), lsp (JSON converters), packages (the core module installers). Hoist those helpers to shared-core position (a within-mod.rs reposition, no behavior change), then extract the now-clean ranges.
  5. The lsp hub + its JSON consumers. lsp, then async, mcp, completion (edge-free once the JSON helpers are hoisted).
  6. The coupled tail. process (→ansi), project (→lsp), completion_framework (→index/lsp), and the editor umbrella (gathering its sub-installers scattered across the window/packages ranges).
  7. Tests. Move the flat mod tests into lua_bindings/tests.rs.

Each tranche is independently green and mergeable; the order is dependency-correct so no tranche introduces an unresolved sibling edge.

Scope of this arc

This arc splits src/lua_bindings.rs only. The other large files the audit lists — src/editor.rs (7,093) and pmacs-gpu/src/main.rs (6,761) — are separate future arcs with their own stable boundaries (editor command groups; GPU input / render-pipeline / layout). Bundling them here would violate the "one stable boundary at a time" discipline. Named as follow-ups, not started.

Categorical bets

  • The compiler is the safety net. Because the split is pure motion, every real breakage is a compile error (missing item, private item) or a failing test — there is no silent behavioral drift to hunt for. That is what makes a mechanical refactor of this size tractable.
  • Widen visibility exactly as far as the move forces, no further. pub(crate)/pub(super) over pub; the goal is the same encapsulation in more files, not a newly-public surface.
  • Stable boundaries only. The pmacs.<domain> seams are API-shaped and long-lived; splitting along them ages well. Splitting by line-count or incidental adjacency would not.

Validation

Per tranche: cargo fmt clean; cargo clippy --all-targets clean under luajit and lua54; full cargo test green under both flavors (the 2,800-line test suite is the behavioral oracle — same tests, same outcomes, new locations). No Lua-side change, so the .lua fixtures and acceptance suites are untouched and must stay green.

As-built

Tranche 0 (this PR). git mv src/lua_bindings.rs src/lua_bindings/mod.rs (the pub mod lua_bindings; in lib.rs resolves to mod.rs unchanged), then extracted the pmacs.diag surface — diagnostic_to_lua + install_diag, moved verbatim — into src/lua_bindings/diag.rs (229 lines). mod.rs declares mod diag; and its one internal call site became diag::install_diag(...); diag.rs reaches shared-core items (BufferIdLua, SharedCore) via super:: and imports externals (SharedLspManager, crate::diag::*) directly. No visibility widening was needed — the child module sees the parent's items, and install_diag's only caller is mod.rs itself. No re-export needed (nothing external references it). mod.rs: 15,202 → 14,986 lines.

Validated: cargo fmt clean; clippy --lib clean under luajit and lua54; full lib suite 1437 passed / 0 failed under luajit (the tests, which drive pmacs.diag.* through the Lua VM, are the behavioral oracle — unchanged outcomes, code merely relocated).

Tranche 1 (this PR). Extracted pmacs.index (the project symbol-index surface) into src/lua_bindings/index.rs (390 lines) — the one remaining clean leaf (its 3 private helpers are used only within its own range). mod.rs declares mod index; and pub use index::{SharedProjectIndexer, make_project_indexer};, which keeps the crate::lua_bindings::… paths in editor.rs + completion_framework.rs (and an in-file completion-framework use) valid. index.rs has zero shared-core coupling — it depends only on crate::project_index, mlua, std, and reaches one stranded helper (lua_to_json, still in the lsp section) via super::. Verbatim move, no logic change. mod.rs: 14,986 → 14,603 lines.

While vetting the next leaves, discovered the recon under-counted the misplaced helpers (see the corrected tranche plan above): parse/theme, window, and minibuffer trail off into shared style/color, caller_ source, and command/menu helpers, so they need the helper-hoist tranche (now #2) before they can be extracted cleanly. This tranche stops at index rather than force those.

Validated: cargo fmt clean; clippy --lib clean under both flavors; full lib suite 1437 passed / 0 failed under both luajit and lua54.

Tranche 2 (this PR). Extracted pmacs.mcp (the MCP client surface) into src/lua_bindings/mcp.rs (602 lines), verbatim. All mcp items are self-contained; it reaches the JSON converters via super::json_to_lua/ lua_to_json and SharedProcessSupervisor via super:: — no hoist needed (see the corrected model in the tranche plan). mod.rs re-exports make_mcp_manager (external caller editor.rs) and McpServerIdLua — the latter to preserve the public-API path crate::lua_bindings:: McpServerIdLua (moving it into a private module had dropped it from the crate surface, surfacing as dead-code on id(); the split must not shrink the public API). mod.rs: 14,603 → 14,020 lines.

Review follow-up: the pub install_* wiring fns (install_diag, install_project_index, install_mcp) were pub before the split but weren't re-exported, silently dropping their crate::lua_bindings::* paths. They take crate-internal handle types (so no external caller can invoke them, and none does), but to keep the split strictly API-preserving they're now re-exported alongside the factories/handles — restoring the paths for the two already-merged tranches too. Deliberately narrowing them to pub(crate) is left as a separate, intentional change.

Validated: cargo fmt clean; clippy --lib clean under both flavors; full lib suite 1437 passed / 0 failed under both luajit and lua54.