use std::any::TypeId;
use std::collections::{BTreeMap, HashSet};
use std::fmt::Write as _;
use std::path::Path;
use bevy::ecs::world::World;
use ts_rs::{TS, TypeVisitor};
use crate::event::ReactEventRegistry;
use crate::message::ReactRegistry;
use crate::request::ReactRequestRegistry;
pub(crate) fn render_typescript(
messages: &ReactRegistry,
requests: &ReactRequestRegistry,
events: &ReactEventRegistry,
) -> String {
let mut collector = TsCollector::default();
for reg in messages.handlers.values() {
(reg.ts_collect)(&mut collector);
}
for reg in requests.handlers.values() {
(reg.ts_collect)(&mut collector);
}
for reg in events.handlers.values() {
(reg.ts_collect)(&mut collector);
}
collector.add::<crate::keyboard::KeyDown>();
collector.add::<crate::keyboard::KeyUp>();
let mut message_names: Vec<(&str, String)> = messages
.handlers
.iter()
.map(|(name, reg)| (*name, (reg.ts_name)()))
.collect();
message_names.sort();
let mut request_rows: Vec<RequestRow> = requests
.handlers
.iter()
.map(|(name, reg)| RequestRow {
name,
request_ts: (reg.ts_request_name)(),
response_ts: (reg.ts_response_name)(),
void: (reg.request_is_void)(),
})
.collect();
request_rows.sort_by(|a, b| a.name.cmp(b.name));
const BUILTIN_EVENTS: [&str; 2] = ["keyDown", "keyUp"];
let mut event_names: Vec<(&str, String)> = events
.handlers
.iter()
.map(|(name, reg)| (*name, (reg.ts_name)()))
.filter(|(name, _)| !BUILTIN_EVENTS.contains(name))
.collect();
event_names.push(("keyDown", <crate::keyboard::KeyDown as TS>::name()));
event_names.push(("keyUp", <crate::keyboard::KeyUp as TS>::name()));
event_names.sort();
let mut out = String::new();
out.push_str(
"// @generated by bevy-react — do not edit by hand.\n\
// Mirrors the Rust `#[react_message]` / `#[react_request]` / `#[react_event]`\n\
// types. Regenerate via your app's `App::export_react_typescript` exporter.\n\n\
import {\n\
\x20 emit as rawEmit,\n\
\x20 request as rawRequest,\n\
\x20 addEventListener as rawAddEventListener,\n\
\x20 removeEventListener as rawRemoveEventListener,\n\
} from \"bevy-react\";\n\n",
);
for decl in collector.decls.values() {
writeln!(out, "export {decl}").unwrap();
}
out.push_str("\n/** Every `emit` name and the payload type it carries. */\n");
out.push_str("export interface ReactMessages {\n");
for (name, ts_name) in &message_names {
writeln!(out, " {}: {ts_name};", json_key(name)).unwrap();
}
out.push_str("}\n\n");
out.push_str("/** Every `request` name and its request/response types. */\n");
out.push_str("export interface ReactRequests {\n");
for row in &request_rows {
let request_ts = if row.void { "null" } else { &row.request_ts };
writeln!(
out,
" {}: {{ request: {request_ts}; response: {} }};",
json_key(row.name),
row.response_ts,
)
.unwrap();
}
out.push_str("}\n\n");
out.push_str("/** Every Bevy → React event name and the payload it carries. */\n");
out.push_str("export interface ReactEvents {\n");
for (name, ts_name) in &event_names {
writeln!(out, " {}: {ts_name};", json_key(name)).unwrap();
}
out.push_str("}\n\n");
out.push_str(
"/** Send a typed app message to the Bevy side. */\n\
export function emit<K extends keyof ReactMessages>(name: K, value: ReactMessages[K]): void {\n\
\x20 rawEmit(name, value);\n\
}\n\n\
/** Send a typed request and await its typed response. */\n\
export function request<K extends keyof ReactRequests>(\n\
\x20 name: K,\n\
\x20 value: ReactRequests[K][\"request\"],\n\
): Promise<ReactRequests[K][\"response\"]> {\n\
\x20 return rawRequest(name, value) as Promise<ReactRequests[K][\"response\"]>;\n\
}\n\n\
/** Subscribe to a typed Bevy → React event. Returns an unsubscribe fn. */\n\
export function on<K extends keyof ReactEvents>(\n\
\x20 name: K,\n\
\x20 cb: (value: ReactEvents[K]) => void,\n\
): () => void {\n\
\x20 rawAddEventListener(name, cb as (value: unknown) => void);\n\
\x20 return () => rawRemoveEventListener(name, cb as (value: unknown) => void);\n\
}\n\n\
/** Unsubscribe a listener previously passed to `on`/`addEventListener`. */\n\
export function removeEventListener<K extends keyof ReactEvents>(\n\
\x20 name: K,\n\
\x20 cb: (value: ReactEvents[K]) => void,\n\
): void {\n\
\x20 rawRemoveEventListener(name, cb as (value: unknown) => void);\n\
}\n\n",
);
out.push_str(&render_bevy_object(&request_rows, &message_names));
out
}
struct RequestRow<'a> {
name: &'a str,
request_ts: String,
response_ts: String,
void: bool,
}
enum ProxyNode<'a> {
Namespace(BTreeMap<String, ProxyNode<'a>>),
Leaf(ProxyLeaf<'a>),
}
enum ProxyLeaf<'a> {
Request(&'a RequestRow<'a>),
Message { name: &'a str, ts_name: &'a str },
}
fn render_bevy_object(requests: &[RequestRow], messages: &[(&str, String)]) -> String {
const RESERVED: [&str; 5] = [
"emit",
"request",
"on",
"addEventListener",
"removeEventListener",
];
let mut root: BTreeMap<String, ProxyNode> = BTreeMap::new();
for row in requests {
let segments: Vec<&str> = row.name.split('.').collect();
insert_proxy(&mut root, &segments, ProxyLeaf::Request(row), row.name);
}
for &(name, ref ts_name) in messages {
let segments: Vec<&str> = name.split('.').collect();
insert_proxy(
&mut root,
&segments,
ProxyLeaf::Message {
name,
ts_name: ts_name.as_str(),
},
name,
);
}
for key in root.keys() {
if RESERVED.contains(&key.as_str()) {
panic!(
"react binding {key:?} collides with a reserved `bevy` method; rename it (e.g. give it a dotted namespace)"
);
}
}
let mut out = String::new();
out.push_str(
"/** Structured, fully typed proxy over every message, request, and event. */\n\
export const bevy = {\n\
\x20 emit,\n\
\x20 request,\n\
\x20 on,\n\
\x20 addEventListener: on,\n\
\x20 removeEventListener,\n",
);
for (key, node) in &root {
render_proxy_node(&mut out, key, node, 1);
}
out.push_str("} as const;\n");
out
}
fn insert_proxy<'a>(
tree: &mut BTreeMap<String, ProxyNode<'a>>,
segments: &[&str],
leaf: ProxyLeaf<'a>,
full_name: &str,
) {
let (head, rest) = segments.split_first().expect("binding name is non-empty");
if rest.is_empty() {
if tree
.insert((*head).to_string(), ProxyNode::Leaf(leaf))
.is_some()
{
panic!(
"react binding name {full_name:?} is ambiguous (used as both a method and a namespace, or claimed by two bindings)"
);
}
return;
}
let child = tree
.entry((*head).to_string())
.or_insert_with(|| ProxyNode::Namespace(BTreeMap::new()));
match child {
ProxyNode::Namespace(children) => insert_proxy(children, rest, leaf, full_name),
ProxyNode::Leaf(_) => panic!(
"react binding name {full_name:?} is ambiguous (used as both a method and a namespace)"
),
}
}
fn render_proxy_node(out: &mut String, key: &str, node: &ProxyNode, depth: usize) {
let indent = " ".repeat(depth);
let method = json_key(key);
match node {
ProxyNode::Leaf(ProxyLeaf::Request(row)) => {
if row.void {
writeln!(
out,
"{indent}{method}(): Promise<{}> {{ return request({:?}, null); }},",
row.response_ts, row.name,
)
.unwrap();
} else {
writeln!(
out,
"{indent}{method}(value: {}): Promise<{}> {{ return request({:?}, value); }},",
row.request_ts, row.response_ts, row.name,
)
.unwrap();
}
}
ProxyNode::Leaf(ProxyLeaf::Message { name, ts_name }) => {
writeln!(
out,
"{indent}{method}(value: {ts_name}): void {{ emit({name:?}, value); }},",
)
.unwrap();
}
ProxyNode::Namespace(children) => {
writeln!(out, "{indent}{method}: {{").unwrap();
for (child_key, child) in children {
render_proxy_node(out, child_key, child, depth + 1);
}
writeln!(out, "{indent}}},").unwrap();
}
}
}
#[derive(Default)]
pub(crate) struct TsCollector {
seen: HashSet<TypeId>,
decls: BTreeMap<String, String>,
}
impl TsCollector {
pub(crate) fn add<T: TS + 'static + ?Sized>(&mut self) {
if self.seen.insert(TypeId::of::<T>()) {
if T::output_path().is_some() {
self.decls.insert(T::name(), T::decl());
}
T::visit_dependencies(self);
T::visit_generics(self);
}
}
}
impl TypeVisitor for TsCollector {
fn visit<T: TS + 'static + ?Sized>(&mut self) {
self.add::<T>();
}
}
fn json_key(name: &str) -> String {
let is_ident = !name.is_empty()
&& name.chars().enumerate().all(|(i, c)| {
c == '_' || c == '$' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
});
if is_ident {
name.to_string()
} else {
format!("{name:?}")
}
}
pub(crate) fn export(world: &World, path: &Path) -> std::io::Result<()> {
let empty_messages = ReactRegistry::default();
let empty_requests = ReactRequestRegistry::default();
let empty_events = ReactEventRegistry::default();
let contents = render_typescript(
world
.get_resource::<ReactRegistry>()
.unwrap_or(&empty_messages),
world
.get_resource::<ReactRequestRegistry>()
.unwrap_or(&empty_requests),
world
.get_resource::<ReactEventRegistry>()
.unwrap_or(&empty_events),
);
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, contents)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ReactAppExt, react_message};
use bevy::prelude::*;
#[derive(Resource, Default)]
struct LastCount(usize);
#[react_message]
struct Count(usize);
#[react_message]
#[allow(dead_code)]
struct Move {
delta: Vec2i,
}
#[derive(serde::Deserialize, ts_rs::TS)]
#[allow(dead_code)]
struct Vec2i {
x: i32,
y: i32,
}
#[crate::react_request(name = "board.get", response = BoardSnapshot)]
#[allow(dead_code)]
struct BoardGet;
#[crate::react_request(name = "pieces.move", response = MoveStatus)]
#[allow(dead_code)]
struct PiecesMove {
to: String,
}
#[derive(serde::Serialize, ts_rs::TS)]
#[allow(dead_code)]
struct BoardSnapshot {
fen: String,
}
#[crate::react_request(name = "pieces.list", response = Vec<PieceInfo>)]
#[allow(dead_code)]
struct PiecesList;
#[derive(serde::Serialize, ts_rs::TS)]
#[allow(dead_code)]
struct PieceInfo {
kind: String,
}
#[derive(serde::Serialize, ts_rs::TS)]
#[allow(dead_code)]
struct MoveStatus {
ok: bool,
}
#[crate::react_event(name = "user.disconnected")]
#[allow(dead_code)]
struct UserDisconnected {
user_id: String,
}
#[test]
fn exports_typescript() {
let mut app = App::new();
app.init_resource::<LastCount>();
app.add_react_handler(|on: On<Count>, mut last: ResMut<LastCount>| last.0 = on.event().0);
app.add_react_message::<Move>();
app.add_react_request::<BoardGet>();
app.add_react_request::<PiecesMove>();
app.add_react_request::<PiecesList>();
app.add_react_event::<UserDisconnected>();
let world = app.world();
let render = || {
render_typescript(
world.resource::<ReactRegistry>(),
world.resource::<ReactRequestRegistry>(),
world.resource::<ReactEventRegistry>(),
)
};
let ts = render();
assert!(ts.contains("export type Count = number;"), "{ts}");
assert!(ts.contains("export type Vec2i = "), "{ts}");
assert!(ts.contains("export type Move = "), "{ts}");
assert!(ts.contains("count: Count;"), "{ts}");
assert!(ts.contains("move: Move;"), "{ts}");
assert!(
ts.contains(r#""board.get": { request: null; response: BoardSnapshot };"#),
"{ts}"
);
assert!(
ts.contains(r#""pieces.move": { request: PiecesMove; response: MoveStatus };"#),
"{ts}"
);
assert!(
ts.contains(r#""user.disconnected": UserDisconnected;"#),
"{ts}"
);
assert!(
ts.contains("export type KeyDown = KeyboardEventData;"),
"{ts}"
);
assert!(
ts.contains("export type KeyUp = KeyboardEventData;"),
"{ts}"
);
assert!(ts.contains("keyDown: KeyDown;"), "{ts}");
assert!(ts.contains("keyUp: KeyUp;"), "{ts}");
assert!(ts.contains("export type PieceInfo = "), "{ts}");
assert!(
ts.contains(r#""pieces.list": { request: null; response: Array<PieceInfo> };"#),
"{ts}"
);
assert!(
ts.contains("export function request<K extends keyof ReactRequests>"),
"{ts}"
);
assert!(
ts.contains(r#"get(): Promise<BoardSnapshot> { return request("board.get", null); }"#),
"{ts}"
);
assert!(
ts.contains(
r#"move(value: PiecesMove): Promise<MoveStatus> { return request("pieces.move", value); }"#
),
"{ts}"
);
assert!(
ts.contains(r#"count(value: Count): void { emit("count", value); }"#),
"{ts}"
);
assert!(
ts.contains(r#"move(value: Move): void { emit("move", value); }"#),
"{ts}"
);
assert_eq!(ts, render());
}
}