RFC-0033: Idiomatic Rust output — borrowed parameters, lint-clean preamble, expression-form state-var initializers

Summary

Three changes that make Frame’s Rust target output look like what an idiomatic Rust developer would write by hand:

  1. 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.
  2. Emit a module-level lint-suppression preamble matching the convention of bindgen / prost / tonic-build so downstream crates running cargo clippy -D warnings don’t trip on Frame-emitted code.
  3. Let state-variable declarations use full expression-form initializers like String::from("default") or Vec::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:

  1. &str in interface parameters. The idiomatic Rust signature for “pass me some text” is fn foo(&self, input: &str). Frame’s Rust target accepts the syntax today, then emits an event variant Bar { input: &str } that rustc rejects with E0106 (missing lifetime). The workaround is to use String everywhere and add .to_string() at every call site — exactly what the user was trying to avoid by reaching for &str in the first place.

  2. 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 warnings reports four distinct lint failures (clippy::derivable_impls, clippy::new_without_default, and two clippy::single_match sites). 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 via include!(), which is the canonical Cargo build-script consumer pattern.

  3. 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 as String::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 like String::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 require T: '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.