Skip to content

feat(plugin): provider functions can declare targets & provider_state - #165

Open
raphaelvigee wants to merge 1 commit into
masterfrom
feat/buildfile-plugin-declarations
Open

feat(plugin): provider functions can declare targets & provider_state#165
raphaelvigee wants to merge 1 commit into
masterfrom
feat/buildfile-plugin-declarations

Conversation

@raphaelvigee

Copy link
Copy Markdown
Member

Why

Authoring a third-party heph plugin today means writing a second cdylib crate that exports heph_plugin_create against the frozen stabby ABI (prost on the cold path, manual log-sink/supervisor exports, an ABI_SEMVER bump ceremony, a *-plugin.json manifest with per-arch artifacts + checksums, and a path:/url: config entry that dlopens it). That's far too heavy for the common case — a tool author (e.g. someone shipping a code generator) who just wants heph users to "pave" their tool: call a rule that runs the tool hermetically with the right inputs/outputs.

Most of those "plugins" don't need a new provider or driver at all — they're rules that expand to a target(driver = "exec", ...). BUILD files are already full Starlark (def + load()), and providers already expose functions as heph.<provider>.<fn>(...). The one missing capability: a provider function could return a value but could not declare a target.

What

This adds that capability — the foundational primitive for "build-file plugins" (and, later, out-of-process JS plugins):

  • ProviderFn::call now returns an FnOutcome (crates/plugin/src/provider.rs): the value substituted at the call site plus any target() / provider_state() the call declared (DeclaredTarget / DeclaredState). Value-only functions build it with Value::into / FnOutcome::value (the common case — glob, join, go.build_addr, …).
  • The buildfile provider merges declarations into the calling package (ProviderNativeFn::invoke) through the same sinks the target() / provider_state() builtins use, so a declared target is indistinguishable from a hand-written one. Call-site provenance is captured (when enabled) and stamped on each declared target, so tooling/LSP traces them back to the heph.<plugin>.<fn>(…) call.
  • Provider functions now accept named arguments. invoke used parse_positional, which rejects all named args via no_named_args, so functions were positional-only. It now uses positions() + names_map(); the declared FnSignature remains the canonical arity/type guard. This is what lets a rule take foo_codegen(name = ..., srcs = ...).

Design notes

  • Wire-friendly by construction. Declarations are returned, not pushed through a side-channel sink, so the shape carries cleanly across a process boundary. This is deliberate: the stated direction is plugins authored in JS running in a built-in sandbox — the plugin returns a declaration bundle, the host merges it.
  • In-process only, fails loudly. The out-of-process plugin ABI (CallFunction) carries the return value alone. Crossing it with a non-empty declaration set is a hard error (both the stabby host and the SDK guest serve path), not a silent drop — extending the ABI to carry declarations is the follow-up that unlocks out-of-process plugins.

Example (what a tool author ships)

A provider function heph.codegen.rule(...) that stands up an exec target:

# in a BUILD file, once the codegen provider is loaded
gen = heph.codegen.rule(name = "gen_a", srcs = glob("*.proto"))

expands to a fully configured target(driver = "exec", run = [...], deps = {...}, out = {...}, codegen = "copy") in the calling package — no cdylib.

Scope / follow-ups

  • This PR is the capability + the in-process wiring, with tests. It does not add a new user-facing provider or the JS sandbox.
  • Follow-ups: extend the plugin ABI (CallFunctionResponse) to carry declarations for out-of-process plugins; a concrete build-file/JS plugin authoring surface on top.

Tests

  • plugin-buildfile: a provider function declares a target + provider_state and both land in the package; empty declared name fails loudly; provider functions accept named args (guards the positions()/names_map() change). Existing provider-function tests still green (47 total).
  • Mechanical FnOutcome propagation verified across builtins (69), plugin-go (4), plugin-sdk w/ stabby (17), plugin-stabby (1).
  • lint + stabby-gated clippy + fmt clean.

🤖 Generated with Claude Code

@raphaelvigee
raphaelvigee force-pushed the feat/buildfile-plugin-declarations branch from 543d880 to 510c371 Compare July 30, 2026 09:41
A provider function (surfaced as `heph.<provider>.<fn>` in BUILD files)
could previously only return a value. It can now also declare `target()`
and `provider_state()` via a new `FnOutcome` return bundle, which the
buildfile provider merges into the calling package as if hand-written.

This is the "build-file plugin" primitive: a tool author can ship a
codegen rule as a provider function wrapping the existing `exec` driver,
instead of a cdylib. The return-bundle shape is wire-friendly, so
out-of-process (e.g. JS) plugins can carry declarations once the ABI is
extended to transport them.

Also: provider functions now accept named arguments. `invoke` used
`parse_positional`, which rejects all named args via `no_named_args`; it
now uses `positions()` + `names_map()`, letting a rule take
`foo_codegen(name = ..., srcs = ...)`. The declared signature remains the
canonical arity/type guard.

Declarations are honored in-process only; crossing the plugin ABI with a
non-empty declaration set is a hard error until the ABI carries them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@raphaelvigee
raphaelvigee force-pushed the feat/buildfile-plugin-declarations branch from 510c371 to 931225a Compare August 1, 2026 21:55
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