Skip to main content

rustlavel_macros/
lib.rs

1//! Derive macros for Rustlavel.
2//!
3//! `#[derive(Model)]` is the Eloquent-shaped half of the ORM: it turns a plain
4//! struct into something that knows its table, can be read from a row, and can
5//! be saved back — without the runtime reflection Laravel relies on.
6
7mod parse;
8
9use parse::{Attributes, Field, Struct};
10use proc_macro::TokenStream;
11
12/// Derive `Model` for a struct with named fields.
13///
14/// ```ignore
15/// #[derive(Model)]
16/// #[model(table = "users")]
17/// pub struct User {
18///     #[model(primary_key, generated)]
19///     pub id: i64,
20///     pub name: String,
21///     pub email: String,
22///     #[model(skip)]
23///     pub transient: Option<String>,
24/// }
25/// ```
26///
27/// Defaults follow Laravel's conventions: the table is the pluralised,
28/// snake_cased struct name, and a field called `id` is the primary key.
29///
30/// The generated code reaches the database package through `::rustlavel::db`,
31/// since that is the dependency an application declares. A crate depending on
32/// `rustlavel-db` directly overrides it with `#[model(crate = "rustlavel_db")]`.
33#[proc_macro_derive(Model, attributes(model))]
34pub fn derive_model(input: TokenStream) -> TokenStream {
35    match parse::parse_struct(input).and_then(expand) {
36        Ok(tokens) => tokens,
37        // A derive cannot return a Result, so the error becomes a compile error
38        // pointing at the real problem instead of a wall of trait bounds.
39        Err(message) => format!("::core::compile_error!({:?});", message).parse().unwrap(),
40    }
41}
42
43fn expand(parsed: Struct) -> Result<TokenStream, String> {
44    let Struct { name, fields, attributes } = parsed;
45
46    let stored: Vec<&Field> = fields.iter().filter(|f| !f.attributes.skip).collect();
47    if stored.is_empty() {
48        return Err(format!("`{name}` has no persisted fields"));
49    }
50
51    let table = attributes.table.clone().unwrap_or_else(|| pluralize(&snake_case(&name)));
52
53    // Applications depend on the meta-crate, not on the database package
54    // directly, so that is the path the generated code takes. A crate using
55    // rustlavel-db on its own says so with `#[model(crate = "rustlavel_db")]`.
56    let db = attributes.krate.clone().unwrap_or_else(|| "::rustlavel::db".to_string());
57
58    let primary = stored
59        .iter()
60        .find(|f| f.attributes.primary_key)
61        .or_else(|| stored.iter().find(|f| column_of(f) == "id"))
62        .ok_or_else(|| {
63            format!(
64                "`{name}` has no primary key. Add a field called `id`, or mark one \
65                 `#[model(primary_key)]`."
66            )
67        })?;
68    let primary_column = column_of(primary);
69    let primary_field = primary.name.clone();
70    let primary_type = primary.type_text.clone();
71
72    // Columns the database fills in are read back but never written.
73    let writable: Vec<&&Field> = stored
74        .iter()
75        .filter(|f| !f.attributes.generated && !f.attributes.primary_key && column_of(f) != "id")
76        .filter(|f| !matches!(column_of(f).as_str(), "created_at" | "updated_at"))
77        .collect();
78
79    let column_list = stored
80        .iter()
81        .map(|f| format!("\"{}\"", column_of(f)))
82        .collect::<Vec<_>>()
83        .join(", ");
84
85    // Only fall back to `Default` when some fields are skipped; emitting it
86    // unconditionally makes clippy complain at every derive site.
87    let rest = if stored.len() == fields.len() {
88        String::new()
89    } else {
90        "            ..::core::default::Default::default()\n".to_string()
91    };
92
93    let from_row = stored
94        .iter()
95        .map(|field| {
96            format!(
97                "            {}: row.get({:?})?,",
98                field.name,
99                column_of(field)
100            )
101        })
102        .collect::<Vec<_>>()
103        .join("\n");
104
105    let to_values = writable
106        .iter()
107        .map(|field| {
108            format!(
109                "            ({:?}, {db}::Value::from(self.{}.clone())),",
110                column_of(field),
111                field.name
112            )
113        })
114        .collect::<Vec<_>>()
115        .join("\n");
116
117    let generated = format!(
118        r#"
119impl {db}::model::Model for {name} {{
120    type Key = {primary_type};
121
122    const TABLE: &'static str = {table:?};
123    const PRIMARY_KEY: &'static str = {primary_column:?};
124    const COLUMNS: &'static str = {column_list:?};
125
126    fn from_row(row: &{db}::Row) -> {db}::Result<Self> {{
127        Ok({name} {{
128{from_row}
129{rest}        }})
130    }}
131
132    fn key(&self) -> Self::Key {{
133        self.{primary_field}.clone()
134    }}
135
136    fn set_key(&mut self, key: Self::Key) {{
137        self.{primary_field} = key;
138    }}
139
140    fn values(&self) -> ::std::vec::Vec<(&'static str, {db}::Value)> {{
141        ::std::vec![
142{to_values}
143        ]
144    }}
145}}
146"#
147    );
148
149    generated.parse().map_err(|e| format!("generated code did not parse: {e}"))
150}
151
152/// The column a field maps to: `#[model(column = "...")]`, else the field name.
153fn column_of(field: &Field) -> String {
154    field.attributes.column.clone().unwrap_or_else(|| field.name.clone())
155}
156
157fn snake_case(input: &str) -> String {
158    let mut out = String::with_capacity(input.len() + 4);
159    for (index, ch) in input.chars().enumerate() {
160        if ch.is_uppercase() {
161            if index > 0 {
162                out.push('_');
163            }
164            out.extend(ch.to_lowercase());
165        } else {
166            out.push(ch);
167        }
168    }
169    out
170}
171
172fn pluralize(word: &str) -> String {
173    if word.ends_with('s') && !word.ends_with("us") && !word.ends_with("ss") {
174        return word.to_string();
175    }
176    if let Some(stem) = word.strip_suffix('y')
177        && !stem.ends_with(['a', 'e', 'i', 'o', 'u']) {
178            return format!("{stem}ies");
179        }
180    if word.ends_with(['s', 'x', 'z']) || word.ends_with("ch") || word.ends_with("sh") {
181        return format!("{word}es");
182    }
183    format!("{word}s")
184}
185
186// Silence the unused warning for a field the expansion reads through helpers.
187const _: Option<&Attributes> = None;
188
189// The parser and helpers are unit-tested here; the generated code is exercised
190// by integration tests in rustlavel-db, which is the only place it can compile.
191
192
193/// `#[rustlavel::main]` — start the application without naming a runtime.
194///
195/// Async Rust needs a runtime, and the standard library ships none: `async fn`
196/// compiles to a state machine that nothing in `std` will drive. So one has to
197/// exist, and here it is Tokio.
198///
199/// What a person using this framework should not have to do is *know* that.
200/// Adding `rustlavel` and then being told to add a second, unrelated-looking
201/// crate before anything compiles is the kind of paper cut that makes a
202/// framework feel like a pile of parts. This attribute and the `rustlavel::tokio`
203/// re-export exist so the runtime is a detail of the framework rather than a
204/// step in the reader's setup.
205///
206/// ```ignore
207/// use rustlavel::prelude::*;
208///
209/// #[rustlavel::main]
210/// async fn main() -> Result<()> {
211///     App::new()?.routes(routes::web::routes).run().await
212/// }
213/// ```
214///
215/// `#[tokio::main]` still works for anyone who wants the runtime in their own
216/// hands — this replaces nothing, it only removes a required step.
217#[proc_macro_attribute]
218pub fn main(_attributes: TokenStream, item: TokenStream) -> TokenStream {
219    runtime_wrapper(item, "main")
220}
221
222/// `#[rustlavel::test]` — the same, for an async test.
223#[proc_macro_attribute]
224pub fn test(_attributes: TokenStream, item: TokenStream) -> TokenStream {
225    runtime_wrapper(item, "test")
226}
227
228/// Rewrite `async fn name(..) -> T { body }` as a blocking `fn` that builds a
229/// runtime and blocks on the body.
230///
231/// Written by hand against the token stream, like everything else in this
232/// crate: no syn, no quote. The shape being rewritten is narrow — an `async fn`
233/// with no arguments — so the parsing can be too, and anything that does not
234/// match gets a message naming what was expected rather than a torrent of
235/// errors from further down the compile.
236fn runtime_wrapper(item: TokenStream, which: &str) -> TokenStream {
237    let source = item.to_string();
238
239    let Some(async_at) = source.find("async fn ") else {
240        return compile_error(&format!(
241            "#[rustlavel::{which}] goes on an `async fn`. This one is not async — if it does \
242             not await anything, it does not need the attribute."
243        ));
244    };
245
246    // Everything before `async` is attributes and visibility, and has to be
247    // kept: `#[allow(...)]` or `pub` above this attribute is still the user's.
248    let leading = &source[..async_at];
249    let rest = &source[async_at + "async ".len()..];
250
251    let Some(brace_at) = rest.find('{') else {
252        return compile_error(&format!(
253            "#[rustlavel::{which}] needs a function with a body."
254        ));
255    };
256    let signature = &rest[..brace_at];
257    let body = &rest[brace_at..];
258
259    let test_attribute = if which == "test" { "#[::core::prelude::v1::test]" } else { "" };
260
261    format!(
262        "{test_attribute}{leading}{signature}{{
263            ::rustlavel::tokio::runtime::Builder::new_multi_thread()
264                .enable_all()
265                .build()
266                .expect(\"the async runtime could not be started\")
267                .block_on(async {body})
268        }}"
269    )
270    .parse()
271    .unwrap_or_else(|error| {
272        compile_error(&format!("#[rustlavel::{which}] produced code that will not parse: {error}"))
273    })
274}
275
276fn compile_error(message: &str) -> TokenStream {
277    format!("::core::compile_error!({:?});", message).parse().expect("a literal parses")
278}
279
280#[cfg(test)]
281mod tests {
282    // Named imports rather than a glob: this crate now exports an attribute
283    // called `test`, and a glob would shadow the one that makes these run.
284    use super::{pluralize, snake_case};
285
286    #[test]
287    fn derives_conventional_table_names() {
288        assert_eq!(pluralize(&snake_case("User")), "users");
289        assert_eq!(pluralize(&snake_case("UserProfile")), "user_profiles");
290        assert_eq!(pluralize(&snake_case("Category")), "categories");
291        assert_eq!(pluralize(&snake_case("Address")), "addresses");
292    }
293}