Skip to content

Declare: rust cargo-probe runner + native-family declaration collection - #620

Closed
apotema wants to merge 1 commit into
mainfrom
feat/rust-declare-cargo-probe
Closed

Declare: rust cargo-probe runner + native-family declaration collection#620
apotema wants to merge 1 commit into
mainfrom
feat/rust-declare-cargo-probe

Conversation

@apotema

@apotema apotema commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Part of the language-plugins epic (labelle-engine#237); implements the assembler side of labelle-engine#774 (Phase 2). The scripting-side foundation (the component!/event! macros + the probe crate + the cross-runner golden) merged in labelle-scripting PR #29; this adds the assembler's runner + collection so a rust game can declare components/events in .rs.

Why this is machinery, not a table row

The lua/ruby declare runners are prebuilt exes RUN OVER the game's script files (argv = the sources). Rust has no interpreter, so — per the recorded decision on #774 (route (a), the compile-and-run probe, spiked with byte-parity + timing evidence) — its runner must GENERATE a probe crate from the game's components/*.rs + events/*.rs, cargo build it against a persistent target dir, run it, and capture the schema JSON — feeding the same parseSchema consumer the lua/ruby runners feed.

What lands

DeclareRunner.kind: RunnerKind (exe_over_files | cargo_probe) — defaulted, so the lua/ruby rows read unchanged; rust is the sole .cargo_probe row. A single discriminant field so assembler#619's fold of this table into plugin.labelle capability rows stays a mechanical move (this PR deliberately does not touch #619 or refactor the table).

runCargoProbe capability-probes the plugin's shipped macro module (native/src/labelle.rs, PR #29 — absent → the SAME graceful skip the lua/ruby tool-dir probe gives), then generates + builds + runs the probe. The generated crate mirrors tools/declare-rs/src/main.rs: pub mod labelle recomposed from the shipped file via #[path], one mod decl_NNNN; per game declaration file. The emitter recovers declaration order from (file!(), line!()), so each file is copied into the probe's src/ under an ordering-encoded name (components first, then events, alphabetical within each — the order the lua/ruby argv carries); file!() then sorts on the prefix and reproduces the cross-runner order. Write-if-different keeps a warm re-generate a no-op cargo build (external target dir, the rust twin of the lua/ruby content-keyed zig cache).

The shared skip + events floor (declareToolAbsent) serve both kinds; the lua/ruby .exe_over_files branch is left byte-for-byte as it shipped. declare_probe_override is the hermetic-test seam (a prebuilt fake probe run with no args), the rust twin of declare_tool_override.

Collection (root.zig): components/*.<ext> + events/*.<ext> now collect for both families (only SCRIPTS differ: embed embeds+registers them, native stages+compiles them). For the native family they feed the declare runner but are NOT concatenated onto s.scripts (nothing embeds). A non-declaring native game still no-ops at the phase's zero-files gate.

Verification

  • zig build + zig build test (Windows, Zig 0.16): the new hermetic unit tests (probe-crate generation, ordered decl modules, the Windows backslash→forward-slash #[path] rule, the Cargo.toml feature/dep wiring, the runner-kind table) pass; no regression vs the pre-existing (CRLF/shell-exec) Windows baseline — ubuntu CI is the authority for the fmt + shell-exec e2e paths.
  • A new e2e test drives the real generate through the cargo-probe override: a components/hunger.rs declaration codegens scripting_components.zig; a non-declaring native game generates nothing. The real probe generation + cargo build is exercised by labelle-scripting's rust-example CI (a released assembler over the real macros), exactly as the lua/ruby tools' behavior is pinned by that repo's goldens, not here.

Follow-up (release-gated)

The examples/rust-game migration to .rs + the CI purity variant + the ordered token-transcript diff needs a released assembler carrying this row (bump ASSEMBLER_VERSION) — that is where the rust-example CI's "native splices skip declare silently" invariant deliberately flips.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D1zt5TCHYqUJBjswJKjnCo

The native scripting family declares components/events in `.rs` the same
way the embed family declares them in `.rb`/`.lua` — but rust has no
interpreter, so its DECLARE_RUNNERS entry cannot be a prebuilt exe run over
the sources. It is a new `.cargo_probe` runner kind (labelle-engine#774,
route (a) — the compile-and-run probe, settled with a spike on the ticket):
the assembler GENERATES a probe crate from the game's components/*.rs +
events/*.rs, `cargo build`s it against a persistent target dir, runs it, and
captures its schema JSON — feeding the SAME `parseSchema` consumer the
lua/ruby runners feed (byte-identical schema across every runner).

## The mechanism (scripting_declare.zig)
- `DeclareRunner.kind: RunnerKind` discriminant (`exe_over_files` |
  `cargo_probe`), defaulted so the lua/ruby rows read unchanged; rust is the
  sole `.cargo_probe` row. Kept a single field so assembler#619's fold of
  the table into plugin.labelle capability rows stays mechanical.
- `runCargoProbe`: capability-probes the plugin's shipped macro module
  (native/src/labelle.rs — the `component!`/`event!` macros + the pure-Rust
  `%.14g` emitter, labelle-scripting PR #29; absent → the SAME graceful skip
  the lua/ruby tool-dir probe gives), generates + builds the probe, runs it.
- The generated crate mirrors tools/declare-rs/src/main.rs: `pub mod labelle`
  recomposed from the shipped file via `#[path]`, one `mod decl_NNNN;` per
  game declaration file. The emitter recovers declaration order from
  `(file!(), line!())`, so each file is COPIED into the probe's `src/` under
  an ordering-encoded name (components first, then events, alphabetical
  within each — the order the lua/ruby argv carries); `file!()` then sorts on
  the prefix and reproduces the cross-runner order. Write-if-different keeps
  the warm re-generate a no-op cargo build.
- The absent-tool skip + the events-floor gate are shared across both kinds
  (`declareToolAbsent`); the lua/ruby `.exe_over_files` branch is left
  byte-for-byte as it shipped.
- `declare_probe_override`: the hermetic-test seam (a prebuilt fake probe run
  with no args), the rust twin of `declare_tool_override` — the suite never
  needs a `cargo` on PATH or a network crate fetch. The real generate+build
  is labelle-scripting's `rust-example` CI.

## The collection (root.zig)
`components/*.<ext>` + `events/*.<ext>` now collect for BOTH families (only
SCRIPTS differ: embed embeds+registers them, native stages+compiles them).
For the native family they feed the declare phase's runner but are NOT
concatenated onto `s.scripts` (nothing embeds). A non-declaring native game
(no `.rs` declarations) still no-ops at the phase's zero-files gate.

## Tests
Hermetic unit tests pin the probe-crate generation (main.rs recomposition +
ordered decl modules, the Windows backslash→forward-slash `#[path]` rule,
the Cargo.toml feature/dep wiring) and the runner-kind table. An e2e test
drives the real `generate` through the cargo-probe override: a
`components/hunger.rs` declaration codegens `scripting_components.zig`; a
non-declaring native game generates nothing.

Part of labelle-engine#774 (Phase 2). Follow-up (release-gated): the
examples/rust-game migration to `.rs` + the CI purity variant, which needs a
released assembler carrying this row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1zt5TCHYqUJBjswJKjnCo
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@apotema, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dfa656bc-883a-43c6-8adc-15bb67856aec

📥 Commits

Reviewing files that changed from the base of the PR and between ca8c7e3 and 426eb7f.

📒 Files selected for processing (3)
  • src/root.zig
  • src/scripting_declare.zig
  • test/native_splice_tests.zig
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rust-declare-cargo-probe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Rust declarations in the scripting system by implementing a new .cargo_probe runner kind. This runner generates a temporary Cargo probe crate from the game's .rs component and event declaration files, builds it, and runs it to extract the schema JSON. The reviewer's feedback suggests simplifying the probeBinName function to be buffer-free and allocation-free, which would also eliminate the need for a temporary buffer in buildRustProbe. Additionally, the reviewer recommends propagating critical errors like OutOfMemory in writeFileAbsIfDifferent instead of silently swallowing them.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/scripting_declare.zig
Comment on lines +1395 to +1399
fn probeBinName(buf: []u8) []const u8 {
if (builtin.os.tag == .windows)
return std.fmt.bufPrint(buf, "labelle-declare-rs.exe", .{}) catch "labelle-declare-rs.exe";
return "labelle-declare-rs";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The probeBinName function takes a buffer and uses std.fmt.bufPrint to format a static string literal. Since both "labelle-declare-rs.exe" and "labelle-declare-rs" are compile-time string literals that coerce directly to []const u8, we can simplify this function to be completely allocation-free and buffer-free.

fn probeBinName() []const u8 {
    if (builtin.os.tag == .windows)
        return "labelle-declare-rs.exe";
    return "labelle-declare-rs";
}

Comment thread src/scripting_declare.zig
Comment on lines +1556 to +1557
var exe_buf: [64]u8 = undefined;
const bin = try std.fs.path.join(allocator, &.{ target_dir, "debug", probeBinName(&exe_buf) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since probeBinName can be simplified to not require a buffer, we can remove the temporary exe_buf array and call probeBinName() directly.

    const bin = try std.fs.path.join(allocator, &.{ target_dir, "debug", probeBinName() });

Comment thread src/scripting_declare.zig
Comment on lines +1462 to +1465
if (cwd.readFileAlloc(io, abs_path, allocator, .limited(4 * 1024 * 1024))) |existing| {
defer allocator.free(existing);
if (std.mem.eql(u8, existing, body)) return;
} else |_| {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In writeFileAbsIfDifferent, cwd.readFileAlloc is called and any error is caught and ignored with else |_| {}. While ignoring errors like FileNotFound is expected, critical errors like OutOfMemory should be propagated rather than silently swallowed.

    if (cwd.readFileAlloc(io, abs_path, allocator, .limited(4 * 1024 * 1024))) |existing| {
        defer allocator.free(existing);
        if (std.mem.eql(u8, existing, body)) return;
    } else |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => {},
    }

@apotema
apotema marked this pull request as draft July 12, 2026 15:37
@apotema

apotema commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the epic owner's architecture decision (2026-07-12): the rust declare lane goes full plugin-declared capability (#619-style, declare slice), not assembler-hardcoded cargo machinery. Per RFC-LANGUAGE-PLUGINS rev 16 §7 ("The language-agnostic assembler"), per-language declare knowledge moves OUT of the assembler into labelle-scripting's plugin.labelle capability rows; the assembler becomes a generic executor of those rows. This PR put the generate-probe-crate → cargo-build → run → capture logic in src/scripting_declare.zig (assembler-side) — exactly the coupling that decision inverts.

Closing in favor of that approach (tracked under #619). The validated findings here are salvaged into the follow-up: the multi-file ordering (copy each decl file to src/decl_NNNN.rs order-encoded so emit_schema's (file!(),line!()) sort reproduces components-then-events), the #[path]-recomposition of the shipped native/src/labelle.rs, and the edition 2021 + optional-inventory/declare-feature probe Cargo.toml — all empirically confirmed to emit correct, correctly-ordered schema JSON against the merged rev-16 labelle.rs.

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