RFC-0033: Idiomatic Rust output — borrowed parameters, lint-clean preamble, expression-form state-var initializers
- Status: Draft
- Author: Mark Truluck mark.truluck@cogiton.com
- Created: 2026-05-19
- Builds on: RFC-0019, RFC-0025
Summary
Three changes that make Frame’s Rust target output look like what an idiomatic Rust developer would write by hand:
- Accept borrowed parameter types (
&str,&[T]) in interface signatures by auto-promoting them to owned types in the event payload while keeping the borrowed signature at the call site. - Emit a module-level lint-suppression preamble matching the
convention of bindgen / prost / tonic-build so downstream crates
running
cargo clippy -D warningsdon’t trip on Frame-emitted code. - Let state-variable declarations use full expression-form
initializers like
String::from("default")orVec::with_capacity(8)instead of only bare-literal forms.
Motivation
A Frame user writing a Rust system today reaches first for the patterns they would use in hand-written Rust — and bounces off three rough edges:
-
&strin interface parameters. The idiomatic Rust signature for “pass me some text” isfn foo(&self, input: &str). Frame’s Rust target accepts the syntax today, then emits an event variantBar { input: &str }that rustc rejects with E0106 (missing lifetime). The workaround is to useStringeverywhere and add.to_string()at every call site — exactly what the user was trying to avoid by reaching for&strin the first place. -
Lint-deny CI gates. The standard Rust quality gate is
cargo clippy -D warnings. Established Rust code generators (bindgen, prost, tonic-build, pest) emit lint-suppression attributes on a wrapping module around their generated content so downstream crates can keep that gate on without false positives. Frame’s Rust output lacks any suppression; on a representative system,cargo clippy -D warningsreports four distinct lint failures (clippy::derivable_impls,clippy::new_without_default, and twoclippy::single_matchsites). A wrapping-module preamble closes them as a class. The wrap matters: inner attributes (#![allow(...)]at file top) work for standalone-module usage but rustc rejects them when the file is pulled in viainclude!(), which is the canonical Cargo build-script consumer pattern. -
State-variable initializers. Frame v4 syntax supports state-local variables with typed defaults:
$.x: String = "hello". For Rust, the bare literal"hello"is&str, so framec wraps it asString::from("hello"). Users who reach for the explicit Rust form$.x: String = String::from("default")hit a parser limitation — the path-expression parser only handles empty-argument forms likeString::new(), dropping the literal inside the parens for any call with arguments. The output is mangled and rustc rejects it.
Each one in isolation is small; together they push the message that “Rust isn’t a first-class Frame target.” This RFC closes them.
The contract
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY are to be interpreted as in RFC 2119.
Borrowed parameter types in interface signatures
Frame’s Rust target MUST accept &str and &[T] (for any type
T) in interface method parameter declarations. The user-facing
system method keeps the borrowed signature. The event variant that
carries the value across the dispatch boundary holds the owned form
(String and Vec<T> respectively). Framec inserts the necessary
.to_string() / .to_vec() at the dispatch site.
Handler bodies bind the parameter back as a borrow, matching the source
type the user wrote — so the handler’s view of input is &str and
the user writes input.len(), input.as_bytes(), etc. against that.
Promotion table
| Frame source type | System method signature | Event variant payload | Dispatch site | Handler binding |
|---|---|---|---|---|
&str |
&str |
String |
input.to_string() |
&str |
&[T] |
&[T] |
Vec<T> |
input.to_vec() |
&[T] |
String |
String |
String |
(no convert) | String |
Vec<T> |
Vec<T> |
Vec<T> |
(no convert) | Vec<T> |
&'static str |
&'static str |
&'static str |
(no convert) | &'static str |
&mut T |
— REJECTED | — | — | — |
&Foo (non-str/slice) |
— REJECTED | — | — | — |
When a parameter type matches a REJECTED row, framec MUST emit a
diagnostic explaining that Frame events are stored owned and suggest
the owned form (Foo instead of &Foo). The diagnostic SHOULD
note that borrowed types ARE supported for &str and &[T] via
auto-promotion, so the user does not assume no borrowed types work.
No lifetime parameters in framec-emitted Rust
Framec MUST NOT emit lifetime parameters (other than 'static) on
any generated Rust type. A lifetime on the event enum would cascade
through the compartment, context, and dispatch machinery, making the
runtime incompatible with the existing Rc<FrameEvent> payload model,
async/await dispatch (which generally requires Send + 'static), and
the persist contract (which serializes events). The owned-payload rule
above keeps this invariant intact.
Module-wrapper lint-suppression preamble
Every @@system <Name> emitted as a Rust target MUST be wrapped
in a private module with OUTER lint-suppression attributes plus a
sibling pub use re-export:
#[allow(dead_code)]
#[allow(non_camel_case_types)]
#[allow(non_snake_case)]
#[allow(unused_variables)]
#[allow(unused_mut)]
#[allow(unused_imports)]
#[allow(clippy::assign_op_pattern)]
#[allow(clippy::clone_on_copy)]
#[allow(clippy::derivable_impls)]
#[allow(clippy::match_single_binding)]
#[allow(clippy::needless_return)]
#[allow(clippy::new_without_default)]
#[allow(clippy::single_match)]
mod _<name>_framec {
use super::*;
// ... all generated content for this system:
// event enum, return enum, value enum, frame context,
// state context, compartment, system struct, impls.
}
pub use _<name>_framec::*;
The module name is _<snake_case_system_name>_framec. The
use super::*; line is required so multi-system files can
reference siblings re-exported at the parent scope; the
pub use _<name>_framec::*; line keeps the public API identical
to the unwrapped form — call sites continue to write
SystemName::new(), system.method(...), etc. with no module
qualifier.
The first six attributes are rustc-level lints inherent to the
codegen shape: intentional dead code in per-system helpers,
Frame’s PascalCase state names becoming method-name segments,
handler parameters not always referenced in every handler body,
etc. The remaining seven are the specific clippy lints framec’s
emission patterns trigger across the canonical fixture corpus.
The set was audited by running cargo clippy -D warnings
against every fixture and collecting every distinct lint that
fires; future framec codegen changes may add new lints to the
list, but the discipline is to add them one-by-one as they
surface, not to use a blanket suppression that hides them.
The preamble MUST NOT include blanket suppressions like
clippy::all, clippy::pedantic, or clippy::nursery. Those
would hide new clippy findings rather than surfacing them to
users, where they can drive future codegen improvements.
framec MUST NOT emit inner attributes (#![allow(...)]) at
the top of the generated file. Inner attributes work for
standalone-module usage but rustc rejects them when the file is
pulled in via include!() (the canonical Cargo build-script
consumer pattern), errors with “an inner attribute is not
permitted in this context.” The wrapping-module form with outer
attributes works in every consumer position.
The per-item #[allow(dead_code)] annotations framec currently
emits MAY remain — they do not conflict with the wrapping-
module umbrella and stay useful for tools that consume framec
output piecewise.
State-variable initializers with expression-form values
A state-variable declaration whose initializer is a Rust path-expression call form MUST parse correctly:
$.x: String = String::from("default")
$.v: Vec<i32> = Vec::with_capacity(8)
$.m: HashMap<String, i32> = HashMap::new()
$.b: Box<MyType> = Box::new(MyType::with("arg"))
The parser MUST accept any path-expression call form
(Type::method(args)) where args is a balanced parenthesized
argument list, including nested calls and string literals. The
initializer reaches the generated Rust verbatim.
Examples
Borrowed string parameter
Frame source:
@@[target("rust")]
@@system Shell {
interface:
run(input: &str): String
machine:
$Active {
run(input: &str): String {
@@:(format!("got {} bytes", input.len()))
}
}
}
Generated Rust (excerpted):
enum ShellFrameEvent {
Run { input: String }, // promoted from &str
FrameEnter { args: Vec<String> },
FrameExit { args: Vec<String> },
}
impl Shell {
pub fn run(&mut self, input: &str) -> String {
let __event = std::rc::Rc::new(ShellFrameEvent::Run {
input: input.to_string(), // promotion at dispatch site
});
// ... dispatch ...
}
}
// In the handler:
fn _s_Active_hdl_user_run(&mut self, __e: &ShellFrameEvent) -> String {
let input: &str = match __e {
ShellFrameEvent::Run { input } => input.as_str(), // re-borrow
_ => unreachable!(),
};
format!("got {} bytes", input.len())
}
Call site is idiomatic Rust:
let mut s = Shell::new();
let result = s.run("hello world"); // no .to_string() at the boundary
Slice parameter
@@system Batch {
interface:
process(items: &[i32]): i32
}
Generated:
enum BatchFrameEvent {
Process { items: Vec<i32> }, // promoted from &[i32]
// ...
}
impl Batch {
pub fn process(&mut self, items: &[i32]) -> i32 {
let __event = std::rc::Rc::new(BatchFrameEvent::Process {
items: items.to_vec(),
});
// ...
}
}
Rejected borrowed shapes
@@system Bad {
interface:
edit(buf: &mut String) // E807: &mut not supported in events
wrap(payload: &Payload) // E807: borrowed non-str/non-slice
}
Diagnostic suggests the owned form (String, Payload) and points to
the &str / &[T] auto-promotion rule.
State-var initializer with expression form
@@system Logger {
interface:
info(msg: &str)
machine:
$A {
$.prefix: String = String::from("[INFO] ")
$.buf: Vec<String> = Vec::with_capacity(16)
info(msg: &str) {
self.buf.push(format!("{}{}", self.prefix, msg))
}
}
}
Both String::from("[INFO] ") and Vec::with_capacity(16) reach the
Rust output unchanged.
Lint-clean integration
// downstream/src/lib.rs
#![deny(warnings)] // strict project policy
mod generated {
include!(concat!(env!("OUT_DIR"), "/logger.rs"));
}
cargo clippy --workspace -- -D warnings runs clean: the framec-emitted
file carries its own #![allow(...)] inner-attribute preamble, which
suppresses every lint that fires on the patterns Frame inherently
produces (single-arm match for the router and per-event dispatch,
derivable Default for the empty state context, new_without_default
for the system struct, etc.).
Alternatives
Borrowed types: lifetime-parameterized event enum
Considered: thread a synthesized lifetime parameter 'a through
FooFrameEvent<'a>, FrameContext<'a>, the compartment, and the
dispatch kernel. Rejected because:
- Frame events live in
Rc<FrameEvent>so they can be shared across handler boundaries and saved/restored under the persist contract.Rc<T>does not requireT: 'static, but every async-task spawn point and every serialize boundary effectively does — a borrowed event blocks async dispatch and persistence. - The lifetime would have to propagate to every framec-emitted type, blowing up the type surface and making the generated Rust output fundamentally different from every other target’s output.
The user’s underlying need (“write &str at the call site”) is fully
satisfied by promoting at the boundary. The only cost is the per-call
.to_string() — already inevitable for any value that crosses a
serialization or persistence boundary.
Borrowed types: validator rejection only
Considered: keep framec from emitting bad code by adding a validator
error when the user writes &str, with a message pointing them to
String. Rejected because it asks the user to write less-idiomatic
Rust to work around a tool limitation. The promotion strategy gives
the user what they actually want.
Lint preamble: per-item annotations only
Considered: expand the existing per-item #[allow(dead_code)] to cover
every lint that fires, instead of one module-level umbrella. Rejected
because the per-item approach does not scale — clippy adds new lints
regularly, and a generator has to either anticipate every one or break
downstream -D warnings gates on every clippy release. The
module-level #![allow(clippy::all)] is the convention bindgen /
prost / tonic-build use precisely because it is forward-compatible
with new lint additions.
State-var initializers: type-aware promotion
Considered: detect when the user wrote String::from("default") and
treat it equivalently to the bare-literal form "default", normalizing
inside the parser. Rejected because it overspecifies — a user who
reaches for String::from(some_expr) or String::from(s.trim())
expects their exact expression to reach the output. Generalizing the
parser to accept any path-expression call form is simpler and lets
the user write whatever Rust they want.
Migration
Source-additive for every change in this RFC:
- Borrowed-type acceptance: code that compiled before continues to
compile; new patterns (
&str,&[T]) are now accepted. - Lint preamble: output that compiled before continues to compile; the preamble is an inner attribute with no runtime effect.
- State-var expression-form initializers: existing bare-literal initializers continue to work; expression-form initializers are now accepted instead of being mangled.
Downstream crates that previously worked around the lint issues with
their own #![allow(...)] wrappers MAY remove those workarounds,
but do not have to — the preamble is idempotent with downstream
allows.
References
- Frame language reference
- Glossary
CHANGELOG.md- RFC-0019 — Runtime layout that motivates the owned-event-payload invariant.
- RFC-0025 — Quality-remediation pass that this RFC’s lint-preamble hygiene change is a continuation of.