use std::collections::{HashMap, HashSet};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{Ident, ItemFn, ItemMod};
use super::graph_ir::{GraphEdge, GraphIR, GraphNode};
use super::parser::TriggerSpec;
fn sj_path(is_cloacina: bool) -> TokenStream {
if is_cloacina {
quote! { crate::serde_json }
} else {
quote! { cloacina_computation_graph::serde_json }
}
}
pub fn generate(ir: &GraphIR, module: &ItemMod) -> syn::Result<TokenStream> {
let functions = extract_functions(module)?;
let function_names: HashSet<String> = functions.keys().cloned().collect();
let node_names: HashSet<String> = ir.nodes.keys().cloned().collect();
for node_name in &node_names {
if !function_names.contains(node_name) {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
format!(
"node '{}' is referenced in the graph topology but no function with that name exists in the module",
node_name
),
));
}
}
for fn_name in &function_names {
if !node_names.contains(fn_name) {
if let Some(func) = functions.get(fn_name) {
return Err(syn::Error::new(
func.sig.ident.span(),
format!(
"function '{}' exists in the module but is not referenced in the graph topology. \
All functions in a computation_graph module must appear in the graph declaration.",
fn_name
),
));
}
}
}
let blocking_nodes: HashSet<String> = functions
.iter()
.filter(|(_, f)| has_blocking_attr(f))
.map(|(name, _)| name.clone())
.collect();
let is_cloacina_crate_early = std::env::var("CARGO_CRATE_NAME")
.map(|n| n == "cloacina")
.unwrap_or(false);
let is_triggerless = matches!(&ir.trigger, TriggerSpec::None);
if is_triggerless {
for node in ir.nodes.values() {
if !node.cache_inputs.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
format!(
"trigger-less computation graphs cannot declare cache inputs \
(node '{}' lists `({})`). Trigger-less graphs receive a \
`&Context<Value>` instead of an `InputCache` — entry nodes \
should be declared as bare names like `entry -> next` and \
their fn signatures should take `(ctx: &Context<Value>)`.",
node.name,
node.cache_inputs.join(", "),
),
));
}
}
}
let compiled_fn = generate_compiled_function(
ir,
&functions,
&blocking_nodes,
is_cloacina_crate_early,
is_triggerless,
)?;
let mod_name = &module.ident;
let vis = &module.vis;
let mod_attrs = &module.attrs;
let content = module
.content
.as_ref()
.map(|(_, items)| items.clone())
.unwrap_or_default();
let compiled_fn_name = format_ident!("{}_compiled", mod_name);
let routing_use_stmts = generate_routing_use_stmts(ir, &functions, mod_name);
let mod_name_str = mod_name.to_string();
let cg_path = quote! { ::cloacina_computation_graph };
let (
legacy_acc_names_expr,
legacy_reaction_mode_expr,
trigger_reactor_expr,
type_binding_check,
) = match &ir.trigger {
TriggerSpec::ByReactor(reactor_name) => {
let reactor_name_lit = reactor_name.clone();
let legacy_accs = quote! { Vec::<String>::new() };
let legacy_mode = quote! { "when_any".to_string() };
let trigger_reactor = quote! { Some(#reactor_name_lit.to_string()) };
(
legacy_accs,
legacy_mode,
trigger_reactor,
proc_macro2::TokenStream::new(),
)
}
TriggerSpec::None => {
let legacy_accs = quote! { Vec::<String>::new() };
let legacy_mode = quote! { "none".to_string() };
let trigger_reactor = quote! { None::<String> };
(
legacy_accs,
legacy_mode,
trigger_reactor,
proc_macro2::TokenStream::new(),
)
}
};
let cloacina_root = if is_cloacina_crate_early {
quote! { crate }
} else {
quote! { cloacina }
};
let cg_runtime_root = if is_cloacina_crate_early {
quote! { crate::computation_graph }
} else {
quote! { cloacina_computation_graph }
};
let sj = sj_path(is_cloacina_crate_early);
let terminal_node_names: Vec<String> = ir
.nodes
.values()
.filter(|n| n.is_terminal)
.map(|n| n.name.clone())
.collect();
let graph_data_json_lit = proc_macro2::Literal::string(&graph_topology_json(ir));
let source_boundary_types = extract_source_boundary_types(ir, &functions);
let input_interface_via_cloacina = build_input_interface_fn(
&source_boundary_types,
"e! { ::cloacina::input_interface },
);
let input_interface_via_workflow = build_input_interface_fn(
&source_boundary_types,
"e! { ::cloacina_workflow::input_interface },
);
let (compiled_fn_body, ctor_body) = if is_triggerless {
let fn_body = quote! {
#vis async fn #compiled_fn_name(
context: &#cloacina_root::Context<#sj::Value>,
) -> #cg_runtime_root::GraphResult {
#[allow(unused_imports)]
use #mod_name::*;
#(#routing_use_stmts)*
#compiled_fn
}
};
let ctor = quote! {
#[cfg(not(feature = "packaged"))]
::cloacina::cloacina_workflow_plugin::inventory::submit! {
::cloacina::cloacina_workflow_plugin::TriggerlessGraphEntry {
name: #mod_name_str,
constructor: || ::cloacina::cloacina_workflow_plugin::TriggerlessGraphRegistration {
name: #mod_name_str.to_string(),
graph_fn: ::std::sync::Arc::new(|context: ::cloacina::cloacina_workflow::Context<#sj::Value>| {
Box::pin(async move {
#compiled_fn_name(&context).await
})
}),
terminal_node_names: vec![#(#terminal_node_names.to_string()),*],
},
}
}
#[cfg(feature = "packaged")]
::cloacina_workflow_plugin::inventory::submit! {
::cloacina_workflow_plugin::TriggerlessGraphEntry {
name: #mod_name_str,
constructor: || ::cloacina_workflow_plugin::TriggerlessGraphRegistration {
name: #mod_name_str.to_string(),
graph_fn: ::std::sync::Arc::new(|context: ::cloacina_workflow::Context<#sj::Value>| {
Box::pin(async move {
#compiled_fn_name(&context).await
})
}),
terminal_node_names: vec![#(#terminal_node_names.to_string()),*],
},
}
}
};
(fn_body, ctor)
} else if is_cloacina_crate_early {
let fn_body = quote! {
#vis async fn #compiled_fn_name(
cache: &crate::computation_graph::InputCache,
) -> crate::computation_graph::GraphResult {
#[allow(unused_imports)]
use #mod_name::*;
#(#routing_use_stmts)*
#compiled_fn
}
};
let ctor = quote! {
cloacina_workflow_plugin::inventory::submit! {
cloacina_workflow_plugin::ComputationGraphEntry {
name: #mod_name_str,
constructor: || cloacina_computation_graph::ComputationGraphRegistration {
graph_fn: std::sync::Arc::new(|cache: cloacina_computation_graph::InputCache| {
Box::pin(async move {
#compiled_fn_name(&cache).await
})
}),
trigger_reactor: #trigger_reactor_expr,
accumulator_names: #legacy_acc_names_expr,
reaction_mode: #legacy_reaction_mode_expr,
},
graph_data_json: #graph_data_json_lit,
input_interface: #input_interface_via_workflow,
}
}
};
(fn_body, ctor)
} else {
let fn_body = quote! {
#vis async fn #compiled_fn_name(
cache: &cloacina_computation_graph::InputCache,
) -> cloacina_computation_graph::GraphResult {
#[allow(unused_imports)]
use #mod_name::*;
#(#routing_use_stmts)*
#compiled_fn
}
};
let ctor = quote! {
#[cfg(not(feature = "packaged"))]
::cloacina::cloacina_workflow_plugin::inventory::submit! {
::cloacina::cloacina_workflow_plugin::ComputationGraphEntry {
name: #mod_name_str,
constructor: || cloacina_computation_graph::ComputationGraphRegistration {
graph_fn: std::sync::Arc::new(|cache: cloacina_computation_graph::InputCache| {
Box::pin(async move {
#compiled_fn_name(&cache).await
})
}),
trigger_reactor: #trigger_reactor_expr,
accumulator_names: #legacy_acc_names_expr,
reaction_mode: #legacy_reaction_mode_expr,
},
graph_data_json: #graph_data_json_lit,
input_interface: #input_interface_via_cloacina,
}
}
#[cfg(feature = "packaged")]
::cloacina_workflow_plugin::inventory::submit! {
::cloacina_workflow_plugin::ComputationGraphEntry {
name: #mod_name_str,
constructor: || cloacina_computation_graph::ComputationGraphRegistration {
graph_fn: std::sync::Arc::new(|cache: cloacina_computation_graph::InputCache| {
Box::pin(async move {
#compiled_fn_name(&cache).await
})
}),
trigger_reactor: #trigger_reactor_expr,
accumulator_names: #legacy_acc_names_expr,
reaction_mode: #legacy_reaction_mode_expr,
},
graph_data_json: #graph_data_json_lit,
input_interface: #input_interface_via_workflow,
}
}
};
(fn_body, ctor)
};
let handle_ident = format_ident!("__CGHandle_{}", mod_name);
let triggerless_graph_impl = if is_triggerless {
quote! {
#[cfg(not(feature = "packaged"))]
impl ::cloacina::cloacina_workflow_plugin::TriggerlessGraph for #handle_ident {
fn compiled_fn() -> ::cloacina::cloacina_workflow_plugin::TriggerlessGraphFn {
::std::sync::Arc::new(|context: ::cloacina::cloacina_workflow::Context<#sj::Value>| {
Box::pin(async move { #compiled_fn_name(&context).await })
})
}
fn terminal_node_names() -> &'static [&'static str] {
&[#(#terminal_node_names),*]
}
}
#[cfg(feature = "packaged")]
impl ::cloacina_workflow_plugin::TriggerlessGraph for #handle_ident {
fn compiled_fn() -> ::cloacina_workflow_plugin::TriggerlessGraphFn {
::std::sync::Arc::new(|context: ::cloacina_workflow::Context<#sj::Value>| {
Box::pin(async move { #compiled_fn_name(&context).await })
})
}
fn terminal_node_names() -> &'static [&'static str] {
&[#(#terminal_node_names),*]
}
}
}
} else {
proc_macro2::TokenStream::new()
};
let graph_handle_decl = quote! {
#[doc(hidden)]
#[allow(non_camel_case_types)]
pub struct #handle_ident;
impl #cg_path::Graph for #handle_ident {
const NAME: &'static str = #mod_name_str;
const IS_TRIGGERLESS: bool = #is_triggerless;
}
#triggerless_graph_impl
};
Ok(quote! {
#(#mod_attrs)*
#vis mod #mod_name {
#(#content)*
}
#compiled_fn_body
#type_binding_check
#graph_handle_decl
#ctor_body
})
}
fn extract_functions(module: &ItemMod) -> syn::Result<HashMap<String, ItemFn>> {
let mut functions = HashMap::new();
if let Some((_, items)) = &module.content {
for item in items {
if let syn::Item::Fn(func) = item {
let name = func.sig.ident.to_string();
functions.insert(name, func.clone());
}
}
} else {
return Err(syn::Error::new(
module.ident.span(),
"computation_graph module must have inline content (use `mod name { ... }`, not `mod name;`)",
));
}
Ok(functions)
}
fn extract_source_boundary_types(
ir: &GraphIR,
functions: &HashMap<String, ItemFn>,
) -> Vec<(String, syn::Type)> {
let mut out: Vec<(String, syn::Type)> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for node_name in &ir.sorted_nodes {
let node = match ir.get_node(node_name) {
Some(n) => n,
None => continue,
};
let func = match functions.get(node_name) {
Some(f) => f,
None => continue,
};
for input in &node.cache_inputs {
if !seen.insert(input.clone()) {
continue;
}
if let Some(ty) = param_type_by_name(func, input) {
out.push((input.clone(), boundary_inner_type(&ty)));
}
}
}
out
}
fn param_type_by_name(func: &ItemFn, name: &str) -> Option<syn::Type> {
for arg in &func.sig.inputs {
if let syn::FnArg::Typed(pt) = arg {
if let syn::Pat::Ident(pi) = &*pt.pat {
if pi.ident == name {
return Some((*pt.ty).clone());
}
}
}
}
None
}
fn boundary_inner_type(ty: &syn::Type) -> syn::Type {
match ty {
syn::Type::Reference(r) => boundary_inner_type(&r.elem),
syn::Type::Path(p) => {
if let Some(seg) = p.path.segments.last() {
if seg.ident == "Option" {
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
return boundary_inner_type(inner);
}
}
}
}
ty.clone()
}
_ => ty.clone(),
}
}
fn build_input_interface_fn(sources: &[(String, syn::Type)], prefix: &TokenStream) -> TokenStream {
if sources.is_empty() {
return quote! { || ::std::string::String::from("[]") };
}
let slot_exprs = sources.iter().map(|(name, ty)| {
quote! {
#prefix::InputSlot::optional(
#name,
{
#[allow(unused_imports)]
use #prefix::{ProbeFallback as _, ProbeTyped as _};
(&#prefix::SchemaProbe::<#ty>::new()).probe_input_schema()
},
::std::option::Option::None,
)
}
});
quote! {
|| {
let slots: ::std::vec::Vec<#prefix::InputSlot> = ::std::vec![ #(#slot_exprs),* ];
#prefix::slots_to_json(&slots)
}
}
}
fn has_blocking_attr(func: &ItemFn) -> bool {
func.attrs.iter().any(|attr| {
if attr.path().is_ident("node") {
if let Ok(meta) = attr.parse_args::<Ident>() {
return meta == "blocking";
}
}
false
})
}
fn generate_compiled_function(
ir: &GraphIR,
functions: &HashMap<String, ItemFn>,
blocking_nodes: &HashSet<String>,
is_cloacina_crate: bool,
is_triggerless: bool,
) -> syn::Result<TokenStream> {
let sj = sj_path(is_cloacina_crate);
let entry_nodes = ir.entry_nodes();
if entry_nodes.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"computation graph has no entry nodes (all nodes have incoming edges — possible cycle)",
));
}
let cache_reads = if is_triggerless {
TokenStream::new()
} else {
generate_cache_reads(ir)
};
let mut exec_stmts = Vec::new();
let mut generated_nodes: HashSet<String> = HashSet::new();
for node_name in &ir.sorted_nodes {
if generated_nodes.contains(node_name) {
continue;
}
let node = ir.get_node(node_name).unwrap();
let stmt = generate_node_execution(
ir,
node,
functions,
blocking_nodes,
&mut generated_nodes,
is_cloacina_crate,
is_triggerless,
)?;
exec_stmts.push(stmt);
}
let graph_result_completed = if is_cloacina_crate {
quote! { crate::computation_graph::GraphResult::completed_with_json(__terminal_results, __terminal_results_json) }
} else {
quote! { cloacina_computation_graph::GraphResult::completed_with_json(__terminal_results, __terminal_results_json) }
};
Ok(quote! {
let mut __terminal_results: Vec<Box<dyn std::any::Any + Send>> = Vec::new();
let mut __terminal_results_json: Vec<#sj::Value> = Vec::new();
#cache_reads
#(#exec_stmts)*
#graph_result_completed
})
}
fn generate_cache_reads(ir: &GraphIR) -> TokenStream {
let mut reads = Vec::new();
let mut seen_inputs: HashSet<String> = HashSet::new();
for node in ir.nodes.values() {
for input in &node.cache_inputs {
if seen_inputs.insert(input.clone()) {
let var_name = format_ident!("__cache_{}", input);
let input_str = input.as_str();
reads.push(quote! {
let #var_name = cache.get(#input_str);
});
}
}
}
quote! { #(#reads)* }
}
fn generate_node_execution(
ir: &GraphIR,
node: &GraphNode,
functions: &HashMap<String, ItemFn>,
blocking_nodes: &HashSet<String>,
generated: &mut HashSet<String>,
is_cloacina_crate: bool,
is_triggerless: bool,
) -> syn::Result<TokenStream> {
if generated.contains(&node.name) {
return Ok(quote! {});
}
generated.insert(node.name.clone());
let sj = sj_path(is_cloacina_crate);
let fn_ident = format_ident!("{}", node.name);
let result_var = format_ident!("__result_{}", node.name);
let is_blocking = blocking_nodes.contains(&node.name);
let args = generate_call_args(ir, node, is_triggerless);
let call = if is_blocking {
let graph_error_path = if is_cloacina_crate {
quote! { crate::computation_graph::GraphError::NodeExecution }
} else {
quote! { cloacina_computation_graph::GraphError::NodeExecution }
};
quote! {
let #result_var = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(async {
#fn_ident(#args).await
})
}).await.map_err(|e| #graph_error_path(
format!("blocking node '{}' panicked: {}", stringify!(#fn_ident), e)
))?;
}
} else {
quote! {
let #result_var = #fn_ident(#args).await;
}
};
if node.edges_out.is_empty() {
let terminal_push = if is_triggerless {
let node_name_str = node.name.to_string();
quote! {
let __serialized: #sj::Value =
#sj::to_value(&#result_var).unwrap_or_else(|e| {
panic!(
"trigger-less graph terminal '{}' must produce a value \
that implements Serialize: {}",
#node_name_str, e
)
});
__terminal_results
.push(Box::new(__serialized) as Box<dyn std::any::Any + Send>);
}
} else {
quote! {
let __serialized: #sj::Value =
#sj::to_value(&#result_var).unwrap_or(#sj::Value::Null);
__terminal_results_json.push(__serialized);
__terminal_results
.push(Box::new(#result_var) as Box<dyn std::any::Any + Send>);
}
};
Ok(quote! {
#call
#terminal_push
})
} else if node.edges_out.len() == 1 {
match &node.edges_out[0] {
GraphEdge::Linear { .. } => {
Ok(call)
}
GraphEdge::Routing { variants } => {
let match_arms = generate_routing_match(
ir,
&node.name,
variants,
functions,
blocking_nodes,
generated,
is_cloacina_crate,
is_triggerless,
)?;
Ok(quote! {
#call
#match_arms
})
}
}
} else {
Ok(call)
}
}
fn generate_call_args(_ir: &GraphIR, node: &GraphNode, is_triggerless: bool) -> TokenStream {
let mut args = Vec::new();
if is_triggerless && node.edges_in.is_empty() {
args.push(quote! { context });
}
for input in &node.cache_inputs {
let var_name = format_ident!("__cache_{}", input);
args.push(quote! { #var_name.as_ref().map(|r| r.as_ref().ok()).flatten() });
}
for incoming in &node.edges_in {
let from_var = format_ident!("__result_{}", incoming.from);
if let Some(variant) = &incoming.variant {
let variant_var =
format_ident!("__variant_{}_{}_{}", incoming.from, variant, node.name);
args.push(quote! { &#variant_var });
} else {
args.push(quote! { &#from_var });
}
}
quote! { #(#args),* }
}
#[allow(clippy::too_many_arguments)]
fn generate_routing_match(
ir: &GraphIR,
from_name: &str,
variants: &[super::graph_ir::GraphRoutingVariant],
functions: &HashMap<String, ItemFn>,
blocking_nodes: &HashSet<String>,
generated: &mut HashSet<String>,
is_cloacina_crate: bool,
is_triggerless: bool,
) -> syn::Result<TokenStream> {
let result_var = format_ident!("__result_{}", from_name);
let mut arms = Vec::new();
for variant in variants {
let variant_ident = format_ident!("{}", variant.variant_name);
let variant_var = format_ident!(
"__variant_{}_{}_{}",
from_name,
variant.variant_name,
variant.target
);
let target_node = ir.get_node(&variant.target).ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
format!("routing target '{}' not found in graph", variant.target),
)
})?;
let downstream = generate_node_execution(
ir,
target_node,
functions,
blocking_nodes,
generated,
is_cloacina_crate,
is_triggerless,
)?;
arms.push(quote! {
#variant_ident(#variant_var) => {
#downstream
}
});
}
Ok(quote! {
match #result_var {
#(#arms)*
}
})
}
fn generate_routing_use_stmts(
ir: &GraphIR,
functions: &HashMap<String, ItemFn>,
mod_name: &Ident,
) -> Vec<TokenStream> {
let mut stmts = Vec::new();
for node in ir.nodes.values() {
let has_routing = node
.edges_out
.iter()
.any(|e| matches!(e, GraphEdge::Routing { .. }));
if !has_routing {
continue;
}
if let Some(func) = functions.get(&node.name) {
if let syn::ReturnType::Type(_, ty) = &func.sig.output {
stmts.push(quote! {
#[allow(unused_imports)]
use #mod_name::#ty::*;
});
}
}
}
stmts
}
pub(super) fn graph_topology_json(ir: &GraphIR) -> String {
use serde_json::{json, Value};
let nodes: Vec<Value> = ir
.sorted_nodes
.iter()
.filter_map(|n| ir.nodes.get(n))
.map(|node| json!({ "id": node.name, "inputs": node.cache_inputs }))
.collect();
let mut edges: Vec<Value> = Vec::new();
for name in &ir.sorted_nodes {
if let Some(node) = ir.nodes.get(name) {
for edge in &node.edges_out {
match edge {
GraphEdge::Linear { target } => edges.push(json!({
"from": node.name,
"to": target,
"label": Value::Null,
})),
GraphEdge::Routing { variants } => {
for v in variants {
edges.push(json!({
"from": node.name,
"to": v.target,
"label": v.variant_name,
}));
}
}
}
}
}
}
serde_json::to_string(&json!({ "nodes": nodes, "edges": edges })).unwrap_or_default()
}
#[cfg(test)]
mod topology_tests {
use super::super::graph_ir::GraphRoutingVariant;
use super::{graph_topology_json, GraphEdge, GraphIR, GraphNode, TriggerSpec};
use std::collections::HashMap;
fn node(name: &str, inputs: &[&str], edges: Vec<GraphEdge>, terminal: bool) -> GraphNode {
GraphNode {
name: name.to_string(),
cache_inputs: inputs.iter().map(|s| s.to_string()).collect(),
edges_out: edges,
edges_in: vec![],
is_terminal: terminal,
}
}
#[test]
fn emits_linear_nodes_and_edges() {
let mut nodes = HashMap::new();
nodes.insert(
"compute".to_string(),
node(
"compute",
&["alpha"],
vec![GraphEdge::Linear {
target: "output".to_string(),
}],
false,
),
);
nodes.insert("output".to_string(), node("output", &[], vec![], true));
let ir = GraphIR {
trigger: TriggerSpec::ByReactor("rx".to_string()),
sorted_nodes: vec!["compute".to_string(), "output".to_string()],
nodes,
};
let v: serde_json::Value = serde_json::from_str(&graph_topology_json(&ir)).unwrap();
let ns = v["nodes"].as_array().unwrap();
assert_eq!(ns.len(), 2);
assert_eq!(ns[0]["id"], "compute");
assert_eq!(ns[0]["inputs"][0], "alpha");
let es = v["edges"].as_array().unwrap();
assert_eq!(es.len(), 1);
assert_eq!(es[0]["from"], "compute");
assert_eq!(es[0]["to"], "output");
assert!(es[0]["label"].is_null(), "linear edges have a null label");
}
#[test]
fn emits_routing_variant_labels() {
let mut nodes = HashMap::new();
nodes.insert(
"decision".to_string(),
node(
"decision",
&["alpha"],
vec![GraphEdge::Routing {
variants: vec![
GraphRoutingVariant {
variant_name: "Trade".to_string(),
target: "handler".to_string(),
},
GraphRoutingVariant {
variant_name: "NoAction".to_string(),
target: "audit".to_string(),
},
],
}],
false,
),
);
nodes.insert("handler".to_string(), node("handler", &[], vec![], true));
nodes.insert("audit".to_string(), node("audit", &[], vec![], true));
let ir = GraphIR {
trigger: TriggerSpec::ByReactor("rx".to_string()),
sorted_nodes: vec![
"decision".to_string(),
"handler".to_string(),
"audit".to_string(),
],
nodes,
};
let v: serde_json::Value = serde_json::from_str(&graph_topology_json(&ir)).unwrap();
let es = v["edges"].as_array().unwrap();
assert_eq!(es.len(), 2, "one edge per routing variant");
let labels: Vec<&str> = es.iter().map(|e| e["label"].as_str().unwrap()).collect();
assert!(
labels.contains(&"Trade") && labels.contains(&"NoAction"),
"{es:?}"
);
}
}