Skip to main content

platform_macros/
lib.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Attribute macros emulating mercury-composable's Java annotations. Java
18//! discovers annotated classes by classpath scanning at startup; Rust has no
19//! runtime scanning, so these macros register each item in a **link-time
20//! inventory** (design D6's ergonomic layer) that `AutoStart` collects at
21//! startup — the user application needs no manual builder wiring:
22//!
23//! ```ignore
24//! #[preload(route = "greeting.demo", instances = 10, typed)]
25//! #[derive(Default)]
26//! struct Greetings;            // impl TypedFunction<GreetingRequest, GreetingResponse>
27//!
28//! #[before_application(sequence = 5)]
29//! struct PreflightCheck;       // impl EntryPoint
30//!
31//! #[main_application]
32//! struct MainApp;              // impl EntryPoint
33//!
34//! platform_core::auto_start_main!();   // the whole main() — Java's AutoStart.main(args)
35//! ```
36//!
37//! Marker attributes placed **below** the primary attribute are consumed by
38//! it, mirroring Java's annotation stacking:
39//!
40//! ```ignore
41//! #[preload(route = "some.system.service", instances = 1)]
42//! #[zero_tracing]              // this route never traces its own executions
43//! struct SystemService;
44//! ```
45//!
46//! `#[optional_service("condition")]` is a **first-class attribute** (the Java
47//! `@OptionalService` analog): it makes a composable function, a websocket
48//! server function, a `#[before_application]` or a `#[main_application]`
49//! conditional on application configuration, and works in **either stacking
50//! order** — above or below the primary attribute:
51//!
52//! ```ignore
53//! #[optional_service("app.env=dev")]   // Java order: the condition on top
54//! #[preload(route = "dev.only.service")]
55//! struct DevOnlyService;
56//! ```
57
58use proc_macro::TokenStream;
59use quote::quote;
60use syn::{parse_macro_input, ItemStruct, LitInt, LitStr};
61
62/// The Java `@PreLoad(route, instances, envInstances)` analog: registers a
63/// composable function at startup.
64///
65/// Parameters:
66/// - `route = "my.function.route"` (required). A **comma-separated list**
67///   registers the same function under several route names (aliases) with the
68///   same instance count and visibility — the Java
69///   `@PreLoad(route = "hello.world, hello.declarative")` behavior. Each name
70///   is whitespace-trimmed; an empty segment is a compile error.
71/// - `instances = N` (default 1)
72/// - `env_instances = "config.key"` — read the instance count from application
73///   configuration at startup, falling back to `instances` (Java `envInstances`)
74/// - `is_private = false` — a PUBLIC function, callable from another
75///   application instance through Event over HTTP. Java `@PreLoad` parity:
76///   preloaded functions are **private by default** (in-instance only);
77///   opt into public visibility explicitly (Java `isPrivate = false`)
78/// - `typed` — the struct implements `TypedFunction<I, O>`; it is wrapped in a
79///   `TypedAdapter` (without this flag the struct must implement
80///   `ComposableFunction`)
81///
82/// Consumed marker attributes (place them below `#[preload]`):
83/// - `#[zero_tracing]` — the Java `@ZeroTracing`: this route's executions are
84///   excluded from distributed-trace recording.
85/// - `#[event_interceptor]` — the Java `@EventInterceptor`: the function
86///   receives the raw envelope (`reply_to`/`cid` intact) and replies manually
87///   via `po.send`; the worker sends no auto-reply on success (also available
88///   as the `interceptor` flag parameter).
89/// - `#[optional_service("condition")]` — see [`optional_service`]: a
90///   first-class attribute that also works stacked *above* this one.
91///
92/// The struct must be a unit struct or implement `Default`.
93#[proc_macro_attribute]
94pub fn preload(args: TokenStream, input: TokenStream) -> TokenStream {
95    let mut route: Option<LitStr> = None;
96    let mut instances: usize = 1;
97    let mut env_instances: Option<LitStr> = None;
98    let mut typed = false;
99    let mut zero_tracing = false;
100    let mut interceptor = false;
101    // Java @PreLoad: isPrivate() default TRUE — a preloaded function is
102    // private unless it explicitly opts into public visibility
103    let mut is_private = true;
104    let parser = syn::meta::parser(|meta| {
105        if meta.path.is_ident("route") {
106            route = Some(meta.value()?.parse()?);
107        } else if meta.path.is_ident("instances") {
108            let lit: LitInt = meta.value()?.parse()?;
109            instances = lit.base10_parse()?;
110        } else if meta.path.is_ident("env_instances") {
111            env_instances = Some(meta.value()?.parse()?);
112        } else if meta.path.is_ident("typed") {
113            typed = true;
114        } else if meta.path.is_ident("zero_tracing") {
115            zero_tracing = true;
116        } else if meta.path.is_ident("interceptor") {
117            interceptor = true;
118        } else if meta.path.is_ident("is_private") {
119            let lit: syn::LitBool = meta.value()?.parse()?;
120            is_private = lit.value();
121        } else {
122            return Err(meta.error(
123                "unknown preload parameter (expected route, instances, env_instances, \
124                 typed, zero_tracing, interceptor, is_private; a conditional registration \
125                 is declared with the separate #[optional_service(\"...\")] attribute)",
126            ));
127        }
128        Ok(())
129    });
130    parse_macro_input!(args with parser);
131    let mut item = parse_macro_input!(input as ItemStruct);
132    let Some(route) = route else {
133        return syn::Error::new_spanned(&item.ident, "#[preload] requires route = \"...\"")
134            .to_compile_error()
135            .into();
136    };
137    // a comma-separated route list declares ALIASES (Java @PreLoad parity);
138    // validate the list shape at compile time — empty segments are an error
139    if let Err(message) = validate_route_list(&route.value()) {
140        return syn::Error::new_spanned(&route, message)
141            .to_compile_error()
142            .into();
143    }
144    // consume stacked marker attributes (Java annotation stacking)
145    zero_tracing |= strip_marker(&mut item, "zero_tracing");
146    interceptor |= strip_marker(&mut item, "event_interceptor");
147    let optional_expr = optional_service_expr(&strip_optional_service(&mut item));
148    let construct = constructor(&item);
149    let factory = if typed {
150        quote!(::platform_core::TypedAdapter::arc(#construct))
151    } else {
152        quote!(::std::sync::Arc::new(#construct))
153    };
154    let env_expr = match &env_instances {
155        Some(key) => quote!(::core::option::Option::Some(#key)),
156        None => quote!(::core::option::Option::None),
157    };
158    let expanded = quote! {
159        #item
160        ::platform_core::inventory::submit! {
161            ::platform_core::registry::PreloadEntry {
162                route: #route,
163                instances: #instances,
164                env_instances: #env_expr,
165                optional_service: #optional_expr,
166                zero_tracing: #zero_tracing,
167                interceptor: #interceptor,
168                is_private: #is_private,
169                factory: || #factory,
170            }
171        }
172    };
173    expanded.into()
174}
175
176/// The Java `@WebSocketService(value, namespace)` analog: registers a
177/// websocket server endpoint declaratively. The annotated struct implements
178/// `ComposableFunction` and receives the session lifecycle events
179/// (`type: open` / `string` / `bytes` / `close`) on its per-connection
180/// `{session}.in` route; replies go to the `tx_path` given in the headers.
181///
182/// ```ignore
183/// #[websocket_service("graph")]                     // /ws/graph/{token}
184/// struct GraphUserInterface;
185///
186/// #[websocket_service(name = "json", namespace = "ws")]
187/// struct JsonPathHandler;
188/// ```
189///
190/// The AppStarter lifecycle collects these entries and registers the URL
191/// paths before the HTTP server starts; the server itself starts when REST
192/// automation is enabled **or** at least one websocket service exists
193/// (Java parity). One function object is created per connection.
194#[proc_macro_attribute]
195pub fn websocket_service(args: TokenStream, input: TokenStream) -> TokenStream {
196    let mut name: Option<LitStr> = None;
197    let mut namespace: Option<LitStr> = None;
198    // Java positional form: #[websocket_service("graph")]
199    if let Ok(positional) = syn::parse::<LitStr>(args.clone()) {
200        name = Some(positional);
201    } else {
202        let parser = syn::meta::parser(|meta| {
203            if meta.path.is_ident("name") || meta.path.is_ident("value") {
204                name = Some(meta.value()?.parse()?);
205            } else if meta.path.is_ident("namespace") {
206                namespace = Some(meta.value()?.parse()?);
207            } else {
208                return Err(meta.error(
209                    "unknown websocket_service parameter (expected a service name literal, \
210                     name/value = \"...\", namespace = \"...\")",
211                ));
212            }
213            Ok(())
214        });
215        parse_macro_input!(args with parser);
216    }
217    let mut item = parse_macro_input!(input as ItemStruct);
218    let Some(name) = name else {
219        return syn::Error::new_spanned(
220            &item.ident,
221            "#[websocket_service] requires a service name, e.g. #[websocket_service(\"graph\")]",
222        )
223        .to_compile_error()
224        .into();
225    };
226    let namespace = namespace.unwrap_or_else(|| LitStr::new("ws", name.span()));
227    // consume a stacked `#[optional_service("...")]` marker (Java @OptionalService)
228    let optional_expr = optional_service_expr(&strip_optional_service(&mut item));
229    let construct = constructor(&item);
230    let expanded = quote! {
231        #item
232        ::platform_core::inventory::submit! {
233            ::platform_core::registry::WsServiceEntry {
234                name: #name,
235                namespace: #namespace,
236                optional_service: #optional_expr,
237                factory: || ::std::sync::Arc::new(#construct),
238            }
239        }
240    };
241    expanded.into()
242}
243
244/// The Java `@BeforeApplication(sequence)` analog: an `EntryPoint` that runs
245/// before functions are registered (validation/compilation work). Lower
246/// sequences run first (default 10; 0 is framework-reserved; a failing hook
247/// aborts startup).
248#[proc_macro_attribute]
249pub fn before_application(args: TokenStream, input: TokenStream) -> TokenStream {
250    entry_point_attribute(args, input, quote!(BeforeAppEntry), 10)
251}
252
253/// The Java `@MainApplication(sequence)` analog: the application entry point
254/// (`EntryPoint`), run after preload. Lower sequences run first (default 10).
255#[proc_macro_attribute]
256pub fn main_application(args: TokenStream, input: TokenStream) -> TokenStream {
257    entry_point_attribute(args, input, quote!(MainAppEntry), 10)
258}
259
260/// The Java `@OptionalService("condition")` analog — a **first-class**
261/// attribute that makes a composable function (`#[preload]`), a websocket
262/// server function (`#[websocket_service]`), a `#[before_application]` or a
263/// `#[main_application]` **conditional on application configuration**: the
264/// item registers only when the condition holds at startup (Java
265/// `Feature.isRequired` semantics — comma-separated OR, `!key` negation,
266/// `key=value` / `key` / `key=` forms, case-insensitive).
267///
268/// Works in **either stacking order**:
269///
270/// ```ignore
271/// #[optional_service("app.env=dev")]      // Java order — condition on top
272/// #[preload(route = "dev.only.service")]
273/// struct DevOnly;
274///
275/// #[preload(route = "also.dev.only")]     // marker order — condition below
276/// #[optional_service("app.env=dev")]
277/// struct AlsoDevOnly;
278/// ```
279///
280/// When written above the primary attribute, this macro expands first and
281/// re-attaches the condition below it, where the primary attribute consumes
282/// it; when written below, the primary attribute consumes it directly. Using
283/// it without one of the four primary attributes is a compile error.
284#[proc_macro_attribute]
285pub fn optional_service(args: TokenStream, input: TokenStream) -> TokenStream {
286    let condition = match syn::parse::<LitStr>(args) {
287        Ok(lit) if !lit.value().trim().is_empty() => lit,
288        _ => {
289            return syn::Error::new(
290                proc_macro2::Span::call_site(),
291                "#[optional_service] requires a condition string, \
292                 e.g. #[optional_service(\"app.env=dev\")]",
293            )
294            .to_compile_error()
295            .into();
296        }
297    };
298    let mut item = parse_macro_input!(input as ItemStruct);
299    // The condition must reach one of the primary attributes still on the item
300    // (they expand after this macro and consume the marker it re-attaches).
301    const PRIMARIES: [&str; 4] = [
302        "preload",
303        "websocket_service",
304        "before_application",
305        "main_application",
306    ];
307    let has_primary = item.attrs.iter().any(|attr| {
308        attr.path()
309            .segments
310            .last()
311            .is_some_and(|seg| PRIMARIES.contains(&seg.ident.to_string().as_str()))
312    });
313    if !has_primary {
314        return syn::Error::new_spanned(
315            &item.ident,
316            "#[optional_service] must be stacked with #[preload], #[websocket_service], \
317             #[before_application] or #[main_application]",
318        )
319        .to_compile_error()
320        .into();
321    }
322    item.attrs
323        .push(syn::parse_quote!(#[optional_service(#condition)]));
324    quote!(#item).into()
325}
326
327/// The Java `@ZeroTracing` analog as a first-class marker attribute:
328/// suppresses the function's own telemetry (no dataset, no span) while the
329/// trace context still flows through for continuity. Order-insensitive —
330/// Java does not require a stacking order and neither does this port: write
331/// it above or below `#[preload]`, or inline as
332/// `#[preload(..., zero_tracing)]`.
333#[proc_macro_attribute]
334pub fn zero_tracing(args: TokenStream, input: TokenStream) -> TokenStream {
335    marker_attribute(args, input, "zero_tracing")
336}
337
338/// The Java `@EventInterceptor` analog as a first-class marker attribute:
339/// the function receives the raw envelope and replies manually; the worker
340/// ignores its returned envelope. Order-insensitive — write it above or
341/// below `#[preload]`, or inline as `#[preload(..., interceptor)]`.
342#[proc_macro_attribute]
343pub fn event_interceptor(args: TokenStream, input: TokenStream) -> TokenStream {
344    marker_attribute(args, input, "event_interceptor")
345}
346
347/// Shared mechanics for the stackable markers (the `#[optional_service]`
348/// self-reattachment pattern): written ABOVE the primary attribute, the
349/// marker expands first, verifies a primary is still on the item and
350/// re-attaches itself below, where the primary consumes it; written BELOW,
351/// the primary strips it before the compiler ever resolves it, so this macro
352/// never runs. A marker with no primary on the item is a compile error.
353fn marker_attribute(args: TokenStream, input: TokenStream, name: &str) -> TokenStream {
354    if !args.is_empty() {
355        return syn::Error::new(
356            proc_macro2::Span::call_site(),
357            format!("#[{name}] takes no arguments"),
358        )
359        .to_compile_error()
360        .into();
361    }
362    let mut item = parse_macro_input!(input as ItemStruct);
363    let has_primary = item.attrs.iter().any(|attr| {
364        attr.path()
365            .segments
366            .last()
367            .is_some_and(|seg| seg.ident == "preload")
368    });
369    if !has_primary {
370        return syn::Error::new_spanned(
371            &item.ident,
372            format!(
373                "#[{name}] must be stacked with #[preload] \
374                 (or use the inline form #[preload(..., {inline})])",
375                inline = if name == "event_interceptor" {
376                    "interceptor"
377                } else {
378                    name
379                }
380            ),
381        )
382        .to_compile_error()
383        .into();
384    }
385    let marker = syn::Ident::new(name, proc_macro2::Span::call_site());
386    item.attrs.push(syn::parse_quote!(#[#marker]));
387    quote!(#item).into()
388}
389
390fn entry_point_attribute(
391    args: TokenStream,
392    input: TokenStream,
393    entry_type: proc_macro2::TokenStream,
394    default_sequence: u32,
395) -> TokenStream {
396    let mut sequence: u32 = default_sequence;
397    let parser = syn::meta::parser(|meta| {
398        if meta.path.is_ident("sequence") {
399            let lit: LitInt = meta.value()?.parse()?;
400            sequence = lit.base10_parse()?;
401            Ok(())
402        } else {
403            Err(meta.error("unknown parameter (expected sequence)"))
404        }
405    });
406    parse_macro_input!(args with parser);
407    let mut item = parse_macro_input!(input as ItemStruct);
408    // consume a stacked `#[optional_service("...")]` marker (Java @OptionalService)
409    let optional_expr = optional_service_expr(&strip_optional_service(&mut item));
410    let construct = constructor(&item);
411    let expanded = quote! {
412        #item
413        ::platform_core::inventory::submit! {
414            ::platform_core::registry::#entry_type {
415                sequence: #sequence,
416                optional_service: #optional_expr,
417                factory: || ::std::sync::Arc::new(#construct),
418            }
419        }
420    };
421    expanded.into()
422}
423
424/// Unit structs construct directly; anything else goes through `Default`
425/// (the Java no-arg-constructor analog).
426fn constructor(item: &ItemStruct) -> proc_macro2::TokenStream {
427    let ident = &item.ident;
428    match item.fields {
429        syn::Fields::Unit => quote!(#ident),
430        _ => quote!(<#ident as ::core::default::Default>::default()),
431    }
432}
433
434/// Remove a stacked marker attribute (e.g. `#[zero_tracing]`) from the item,
435/// returning whether it was present.
436fn strip_marker(item: &mut ItemStruct, name: &str) -> bool {
437    let before = item.attrs.len();
438    item.attrs.retain(|attr| !attr.path().is_ident(name));
439    item.attrs.len() != before
440}
441
442/// Consume a stacked `#[optional_service("condition")]` marker (Java
443/// `@OptionalService`), returning its condition string literal. Removes the
444/// attribute so it does not reach the compiler.
445fn strip_optional_service(item: &mut ItemStruct) -> Option<LitStr> {
446    let mut found = None;
447    item.attrs.retain(|attr| {
448        if attr.path().is_ident("optional_service") {
449            if let Ok(lit) = attr.parse_args::<LitStr>() {
450                found = Some(lit);
451            }
452            false
453        } else {
454            true
455        }
456    });
457    found
458}
459
460/// Render an `Option<&'static str>` initializer for a registry entry's
461/// `optional_service` field.
462fn optional_service_expr(cond: &Option<LitStr>) -> proc_macro2::TokenStream {
463    match cond {
464        Some(c) => quote!(::core::option::Option::Some(#c)),
465        None => quote!(::core::option::Option::None),
466    }
467}
468
469/// Validate a `#[preload]` route value: one route name, or a comma-separated
470/// list of route names (aliases — Java `@PreLoad(route = "a.b, c.d")`). Each
471/// segment is whitespace-trimmed; an **empty segment** (leading/trailing/
472/// doubled comma, or a blank value) is rejected here at compile time.
473/// Route-name *shape* (lowercase, at least one dot) stays a startup-time
474/// check in `Platform::register`, exactly as for a single route.
475fn validate_route_list(route: &str) -> Result<(), String> {
476    let segments: Vec<&str> = route.split(',').map(str::trim).collect();
477    if segments.iter().any(|segment| segment.is_empty()) {
478        return Err(format!(
479            "invalid #[preload] route list '{route}' - each comma-separated \
480             route name must be non-empty"
481        ));
482    }
483    Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488    use super::validate_route_list;
489
490    #[test]
491    fn single_route_is_valid() {
492        assert!(validate_route_list("hello.world").is_ok());
493    }
494
495    #[test]
496    fn comma_separated_aliases_are_valid_with_or_without_spaces() {
497        assert!(validate_route_list("hello.world, hello.declarative").is_ok());
498        assert!(validate_route_list("hello.world,hello.declarative").is_ok());
499        assert!(validate_route_list("a.b , c.d ,  e.f").is_ok());
500    }
501
502    #[test]
503    fn empty_segments_are_rejected() {
504        assert!(validate_route_list("").is_err());
505        assert!(validate_route_list("   ").is_err());
506        assert!(validate_route_list("hello.world,").is_err());
507        assert!(validate_route_list(",hello.world").is_err());
508        assert!(validate_route_list("hello.world,,hello.declarative").is_err());
509        assert!(validate_route_list("hello.world, ,hello.declarative").is_err());
510    }
511}