1use std::collections::{BTreeMap, BTreeSet};
14use std::fs;
15use std::path::Path;
16
17use anyhow::{Context, Result, anyhow};
18use syn::{Attribute, FnArg, ItemFn, ItemStruct, ReturnType};
19
20pub fn generate_ts_client(rust_dir: &Path, out: &Path) -> Result<()> {
30 let kind = output_kind(out);
31 debug_assert!(matches!(
32 kind,
33 OutputKind::TypeScriptModule | OutputKind::BrowserGlobalJs
34 ));
35 write_artifact(rust_dir, out, kind)
36}
37
38pub fn generate_rust_registry(rust_dir: &Path, out: &Path) -> Result<()> {
45 let kind = output_kind(out);
46 debug_assert_eq!(kind, OutputKind::RustModule);
47 write_artifact(rust_dir, out, kind)
48}
49
50fn write_artifact(rust_dir: &Path, out: &Path, kind: OutputKind) -> Result<()> {
51 if !rust_dir.exists() {
52 return Err(anyhow!(
53 "Native Rust API directory not found: {}",
54 rust_dir.display()
55 ));
56 }
57 let manifest = scan(rust_dir)?;
58 if manifest.routes.is_empty() && kind != OutputKind::RustModule {
62 let _ = fs::remove_file(out);
63 return Ok(());
64 }
65 let generated = render(&manifest, kind)?;
66 let needs_write = fs::read_to_string(out)
67 .map(|existing| existing != generated)
68 .unwrap_or(true);
69 if needs_write {
70 if let Some(parent) = out.parent() {
71 fs::create_dir_all(parent)
72 .with_context(|| format!("Failed to create {}", parent.display()))?;
73 }
74 fs::write(out, generated).with_context(|| format!("Failed to write {}", out.display()))?;
75 }
76 Ok(())
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80enum OutputKind {
81 TypeScriptModule,
82 BrowserGlobalJs,
83 RustModule,
87}
88
89fn output_kind(out: &Path) -> OutputKind {
90 match out.extension().and_then(|ext| ext.to_str()) {
91 Some(ext) if ext.eq_ignore_ascii_case("js") => OutputKind::BrowserGlobalJs,
92 Some(ext) if ext.eq_ignore_ascii_case("rs") => OutputKind::RustModule,
93 _ => OutputKind::TypeScriptModule,
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
102enum RouteKind {
103 Call,
104 Stream,
105 Channel,
106}
107
108#[derive(Debug, Clone)]
109struct NativeRoute {
110 route: String,
111 kind: RouteKind,
112 input: Option<String>,
113 output: Option<String>,
114 event: Option<String>,
115 channel_in: Option<String>,
116 channel_out: Option<String>,
117 fn_ident: String,
120}
121
122#[derive(Debug, Clone)]
123struct StructField {
124 name: String,
125 ty: String,
126 optional: bool,
127}
128
129#[derive(Debug)]
130struct NativeManifest {
131 routes: Vec<NativeRoute>,
132 structs: BTreeMap<String, Vec<StructField>>,
133}
134
135fn scan(src_dir: &Path) -> Result<NativeManifest> {
140 let mut manifest = NativeManifest {
141 routes: Vec::new(),
142 structs: BTreeMap::new(),
143 };
144
145 let mut files = Vec::new();
146 collect_rs_files(src_dir, &mut files).map_err(|e| anyhow!("scan: {e}"))?;
147
148 for file in &files {
149 let source =
150 fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
151 let ast = syn::parse_file(&source).with_context(|| format!("parse {}", file.display()))?;
152
153 for item in &ast.items {
154 if let syn::Item::Fn(item_fn) = item
155 && let Some((route, kind)) = parse_attr(&item_fn.attrs)
156 {
157 manifest
158 .routes
159 .push(extract_route_info(&route, kind, item_fn));
160 }
161 if let syn::Item::Struct(item_struct) = item {
162 let fields = extract_struct_fields(item_struct);
163 if !fields.is_empty() {
164 manifest
165 .structs
166 .insert(item_struct.ident.to_string(), fields);
167 }
168 }
169 }
170 }
171
172 let mut seen = BTreeSet::new();
174 for r in &manifest.routes {
175 if !seen.insert(r.route.clone()) {
176 return Err(anyhow!("duplicate native route `{}`", r.route));
177 }
178 }
179
180 manifest.routes.sort_by(|a, b| a.route.cmp(&b.route));
181 Ok(manifest)
182}
183
184fn collect_rs_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<(), String> {
185 for entry in fs::read_dir(dir).map_err(|e| format!("read_dir {}: {e}", dir.display()))? {
186 let entry = entry.map_err(|e| e.to_string())?;
187 let path = entry.path();
188 if path.is_dir() {
189 collect_rs_files(&path, out)?;
190 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
191 out.push(path);
192 }
193 }
194 Ok(())
195}
196
197fn parse_attr(attrs: &[Attribute]) -> Option<(String, RouteKind)> {
199 for attr in attrs {
200 let is_match = attr.path().is_ident("native")
201 || attr
202 .path()
203 .segments
204 .iter()
205 .map(|s| s.ident.to_string())
206 .collect::<Vec<_>>()
207 .join("::")
208 .ends_with("lingxia::native");
209 if !is_match {
210 continue;
211 }
212
213 let args: String = attr
214 .meta
215 .require_list()
216 .ok()?
217 .tokens
218 .clone()
219 .into_iter()
220 .map(|t| t.to_string())
221 .collect::<Vec<_>>()
222 .join("");
223
224 let route = args.split('"').nth(1).map(str::to_owned)?;
225 let rest = args.split('"').nth(2).unwrap_or("");
226
227 let kind = if rest.contains("channel") {
228 RouteKind::Channel
229 } else if rest.contains("stream") {
230 RouteKind::Stream
231 } else {
232 RouteKind::Call
233 };
234
235 return Some((route, kind));
236 }
237 None
238}
239
240fn extract_route_info(route: &str, kind: RouteKind, item_fn: &ItemFn) -> NativeRoute {
241 let mut input: Option<String> = None;
242 let mut event: Option<String> = None;
243 let mut channel_in: Option<String> = None;
244 let mut channel_out: Option<String> = None;
245 let mut output: Option<String> = None;
246
247 for arg in &item_fn.sig.inputs {
248 let FnArg::Typed(pat_type) = arg else {
249 continue;
250 };
251 let ty_str = type_string(&pat_type.ty).replace(' ', "");
252
253 if ty_str.contains("LxApp") || ty_str.contains("HostCancel") {
254 continue;
255 }
256
257 if ty_str.contains("StreamContext") {
258 let args = extract_generic_args(&ty_str, "StreamContext");
259 event = args.first().cloned();
260 if let Some(result) = args.get(1) {
261 output = Some(result.clone());
262 }
263 continue;
264 }
265
266 if ty_str.contains("ChannelContext") {
267 let args = extract_generic_args(&ty_str, "ChannelContext");
268 channel_in = args.first().cloned();
269 channel_out = args.get(1).cloned().or_else(|| channel_in.clone());
270 continue;
271 }
272
273 input = Some(ty_str);
274 }
275
276 if output.is_none() {
277 output = match &item_fn.sig.output {
278 ReturnType::Type(_, ty) => {
279 let s = type_string(ty).replace(' ', "");
280 unwrap_result(&s)
281 }
282 ReturnType::Default => Some("void".to_string()),
283 };
284 }
285
286 NativeRoute {
287 route: route.to_string(),
288 kind,
289 input,
290 output,
291 event,
292 channel_in,
293 channel_out,
294 fn_ident: item_fn.sig.ident.to_string(),
295 }
296}
297
298fn extract_struct_fields(item: &ItemStruct) -> Vec<StructField> {
299 item.fields
300 .iter()
301 .filter_map(|field| {
302 let name = field.ident.as_ref()?.to_string();
303 let ty_str = type_string(&field.ty).replace(' ', "");
304 let optional = ty_str.starts_with("Option<");
305 Some(StructField {
306 name: to_camel_case(&name),
307 ty: ty_str,
308 optional,
309 })
310 })
311 .collect()
312}
313
314fn extract_generic_args(ty: &str, wrapper: &str) -> Vec<String> {
315 let Some(pos) = ty
316 .rfind(&format!("{wrapper}<"))
317 .or_else(|| ty.find(&format!("{wrapper}<")))
318 else {
319 return vec![];
320 };
321 let start = pos + wrapper.len();
322 if ty.as_bytes().get(start) != Some(&b'<') {
323 return vec![];
324 }
325 let Some(end) = matching_angle(ty, start) else {
326 return vec![];
327 };
328 let body = match ty.get(start + 1..end) {
329 Some(body) => body,
330 None => return vec![],
331 };
332 split_args(body)
333 .into_iter()
334 .map(|s| s.trim().to_string())
335 .filter(|s| !s.is_empty())
336 .collect()
337}
338
339fn split_args(s: &str) -> Vec<String> {
340 let mut out = Vec::new();
341 let mut depth = 0i32;
342 let mut start = 0usize;
343 for (i, ch) in s.char_indices() {
344 match ch {
345 '<' => depth += 1,
346 '>' => depth -= 1,
347 ',' if depth == 0 => {
348 out.push(s[start..i].to_string());
349 start = i + 1;
350 }
351 _ => {}
352 }
353 }
354 out.push(s[start..].to_string());
355 out
356}
357
358fn unwrap_result(ty: &str) -> Option<String> {
359 for wrapper in &[
360 "Result",
361 "std::result::Result",
362 "HostResult",
363 "lingxia::Result",
364 ] {
365 let args = extract_generic_args(ty, wrapper);
366 if !args.is_empty() {
367 let inner = args.first().cloned().unwrap_or_else(|| "void".to_string());
368 return Some(inner.trim().to_string());
369 }
370 }
371 if ty == "()" {
372 Some("void".to_string())
373 } else {
374 Some(ty.to_string())
375 }
376}
377
378fn render(manifest: &NativeManifest, output_kind: OutputKind) -> Result<String> {
383 match output_kind {
384 OutputKind::TypeScriptModule => render_ts_module(manifest),
385 OutputKind::BrowserGlobalJs => render_browser_global_js(manifest),
386 OutputKind::RustModule => render_rust_module(manifest),
387 }
388}
389
390fn render_rust_module(manifest: &NativeManifest) -> Result<String> {
405 let mut out = String::new();
406 out.push_str("// Generated by `cargo build`. Do not edit by hand.\n");
407 out.push_str("//\n");
408 out.push_str("// Registers every `#[lingxia::native]` handler discovered in this crate.\n");
409 out.push_str("// `include!` this file into your crate root and call\n");
410 out.push_str("// `__lingxia_native::install()` from\n");
411 out.push_str("// `HostAddon::install_logic_extensions`.\n");
412 out.push_str("#[allow(non_snake_case)]\n");
413 out.push_str("mod __lingxia_native {\n");
414 out.push_str(" pub fn install() {\n");
415 for r in &manifest.routes {
416 out.push_str(&format!(
417 " ::lingxia::host::register_host_entry(crate::{}_host());\n",
418 r.fn_ident
419 ));
420 }
421 out.push_str(" }\n");
422 out.push_str("}\n");
423 Ok(out)
424}
425
426fn render_ts_module(manifest: &NativeManifest) -> Result<String> {
427 let mut used_types = BTreeSet::new();
428 for r in &manifest.routes {
429 collect_type_ref(r.input.as_deref(), &mut used_types);
430 collect_type_ref(r.output.as_deref(), &mut used_types);
431 collect_type_ref(r.event.as_deref(), &mut used_types);
432 collect_type_ref(r.channel_in.as_deref(), &mut used_types);
433 collect_type_ref(r.channel_out.as_deref(), &mut used_types);
434 }
435
436 let mut out = String::new();
437 out.push_str("// Generated by `cargo build`. Do not edit by hand.\n");
438 out.push_str("import { channel, invoke, stream } from \"@lingxia/bridge\";\n");
439 out.push_str("import type { NativeChannel, NativeStream } from \"@lingxia/bridge\";\n\n");
440 out.push_str("export type NativeVoid = void;\n\n");
441
442 for ty in &used_types {
443 if !is_builtin_ts(ty) {
444 if let Some(fields) = manifest.structs.get(ty) {
445 out.push_str(&format!("export interface {ty} {{\n"));
446 for f in fields {
447 let opt = if f.optional { "?" } else { "" };
448 out.push_str(&format!(
449 " {}{}: {};\n",
450 f.name,
451 opt,
452 rust_to_ts(clean_option(&f.ty))
453 ));
454 }
455 out.push_str("}\n\n");
456 } else {
457 out.push_str(&format!("export type {ty} = unknown;\n\n"));
458 }
459 }
460 }
461
462 let tree = RouteNode::build(&manifest.routes)?;
463 out.push_str("export const native = ");
464 out.push_str(&tree.render(0));
465 out.push_str(";\n");
466 Ok(out)
467}
468
469fn render_browser_global_js(manifest: &NativeManifest) -> Result<String> {
470 let tree = RouteNode::build(&manifest.routes)?;
471 let mut out = String::new();
472 out.push_str(NATIVE_CLIENT_JS_PREAMBLE);
473 out.push_str(" global.native = ");
474 out.push_str(&tree.render_js(2));
475 out.push_str(NATIVE_CLIENT_JS_FOOTER);
476 Ok(out)
477}
478
479const NATIVE_CLIENT_JS_PREAMBLE: &str = r#"// Generated by `cargo build`. Do not edit by hand.
480(function (global) {
481 function bridge() {
482 if (!global.LingXiaBridge) throw new Error('window.LingXiaBridge is not available');
483 return global.LingXiaBridge;
484 }
485 function route(parts) {
486 return 'host.' + parts.join('.');
487 }
488 function nativeError(error) {
489 if (error && typeof error === 'object') {
490 var code = typeof error.code === 'string' && error.code ? error.code : 'BRIDGE_INTERNAL_ERROR';
491 var message = typeof error.message === 'string' && error.message ? error.message : 'Unknown error';
492 var out = { code: code, message: message };
493 if ('data' in error) out.data = error.data;
494 return out;
495 }
496 return { code: 'BRIDGE_INTERNAL_ERROR', message: error instanceof Error ? error.message : String(error || 'Unknown error') };
497 }
498 function call(parts) {
499 return function (input) {
500 return bridge().raw.call(route(parts), arguments.length === 0 ? undefined : input, { cap: 'host' }).catch(function (error) { return Promise.reject(nativeError(error)); });
501 };
502 }
503 function stream(parts) {
504 return function (input) {
505 var handle = bridge().raw.stream(route(parts), arguments.length === 0 ? undefined : input, { cap: 'host', timeoutMs: 0 });
506 var eventListeners = [];
507 var errorListeners = [];
508 handle.on('data', function (event) { eventListeners.slice().forEach(function (listener) { listener(event); }); });
509 handle.on('error', function (error) { var normalized = nativeError(error); errorListeners.slice().forEach(function (listener) { listener(normalized); }); });
510 return {
511 onEvent: function (listener) { eventListeners.push(listener); return function () { eventListeners = eventListeners.filter(function (item) { return item !== listener; }); }; },
512 onError: function (listener) { errorListeners.push(listener); return function () { errorListeners = errorListeners.filter(function (item) { return item !== listener; }); }; },
513 result: handle.result.catch(function (error) { return Promise.reject(nativeError(error)); }),
514 cancel: function () { handle.cancel(); }
515 };
516 };
517 }
518 function channel(parts) {
519 return function (input) {
520 return bridge().raw.channel.open(route(parts), arguments.length === 0 ? undefined : input, { cap: 'host' }).then(function (handle) {
521 var messageListeners = [];
522 var closeListeners = [];
523 handle.on('data', function (message) { messageListeners.slice().forEach(function (listener) { listener(message); }); });
524 handle.on('close', function (code, reason) { var event = { code: code, reason: reason }; closeListeners.slice().forEach(function (listener) { listener(event); }); });
525 return {
526 send: function (message) { handle.send(message); },
527 onMessage: function (listener) { messageListeners.push(listener); return function () { messageListeners = messageListeners.filter(function (item) { return item !== listener; }); }; },
528 onClose: function (listener) { closeListeners.push(listener); return function () { closeListeners = closeListeners.filter(function (item) { return item !== listener; }); }; },
529 close: function (code, reason) { handle.close(code, reason); }
530 };
531 }).catch(function (error) { return Promise.reject(nativeError(error)); });
532 };
533 }
534"#;
535
536const NATIVE_CLIENT_JS_FOOTER: &str = r#";
537})(window);
538"#;
539
540fn collect_type_ref(ty: Option<&str>, set: &mut BTreeSet<String>) {
541 let Some(ty) = ty else { return };
542 let cleaned = ty.trim();
543 if cleaned.is_empty() || cleaned == "void" || cleaned == "()" {
544 return;
545 }
546 for wrapper in &["Option", "Vec"] {
547 let args = extract_generic_args(cleaned, wrapper);
548 if !args.is_empty() {
549 collect_type_ref(args.first().map(String::as_str), set);
550 return;
551 }
552 }
553 for wrapper in &["HashMap", "BTreeMap"] {
554 let args = extract_generic_args(cleaned, wrapper);
555 if !args.is_empty() {
556 collect_type_ref(args.get(1).map(String::as_str), set);
557 return;
558 }
559 }
560 let base = type_basename(cleaned);
561 if !is_builtin_ts(base) && base.chars().next().is_some_and(|ch| ch.is_uppercase()) {
562 set.insert(base.to_string());
563 }
564}
565
566fn is_builtin_ts(ty: &str) -> bool {
567 matches!(
568 ty,
569 "string"
570 | "boolean"
571 | "number"
572 | "void"
573 | "()"
574 | "unknown"
575 | "any"
576 | "never"
577 | "String"
578 | "bool"
579 )
580}
581
582fn rust_to_ts(ty: &str) -> String {
583 let ty = ty.trim().trim_start_matches('&').trim_start_matches("mut ");
584 if let Some(inner) = clean_option(ty)
585 .strip_prefix("Vec<")
586 .and_then(|r| r.strip_suffix('>'))
587 {
588 return format!("{}[]", rust_to_ts(inner));
589 }
590 let option_args = extract_generic_args(ty, "Option");
591 if let Some(inner) = option_args.first() {
592 return rust_to_ts(inner);
593 }
594 let vec_args = extract_generic_args(ty, "Vec");
595 if let Some(inner) = vec_args.first() {
596 return format!("{}[]", rust_to_ts(inner));
597 }
598 for wrapper in ["HashMap", "BTreeMap"] {
599 let args = extract_generic_args(ty, wrapper);
600 if let Some(value) = args.get(1) {
601 return format!("Record<string, {}>", rust_to_ts(value));
602 }
603 }
604 match type_basename(ty) {
605 "String" | "str" => "string".to_string(),
606 "bool" => "boolean".to_string(),
607 "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" | "f32"
608 | "f64" => "number".to_string(),
609 "()" | "void" => "void".to_string(),
610 "Value" | "JsonValue" => "unknown".to_string(),
611 other => other.to_string(),
612 }
613}
614
615fn clean_option(ty: &str) -> &str {
616 ty.strip_prefix("Option<")
617 .and_then(|r| r.strip_suffix('>'))
618 .unwrap_or(ty)
619}
620
621fn type_basename(ty: &str) -> &str {
622 ty.trim()
623 .trim_start_matches('&')
624 .trim_start_matches("mut ")
625 .split("::")
626 .last()
627 .unwrap_or(ty)
628}
629
630#[derive(Default)]
635struct RouteNode {
636 children: BTreeMap<String, RouteNode>,
637 route: Option<NativeRoute>,
638}
639
640impl RouteNode {
641 fn build(routes: &[NativeRoute]) -> Result<Self> {
642 let mut root = RouteNode::default();
643 for r in routes {
644 let mut node = &mut root;
645 for part in r.route.split('.') {
646 if part.trim().is_empty() {
647 return Err(anyhow!("invalid native route `{}`", r.route));
648 }
649 if node.route.is_some() {
650 return Err(anyhow!(
651 "native route `{}` conflicts with route prefix",
652 r.route
653 ));
654 }
655 node = node.children.entry(part.to_string()).or_default();
656 }
657 if node.route.is_some() || !node.children.is_empty() {
658 return Err(anyhow!(
659 "native route `{}` conflicts with existing route namespace",
660 r.route
661 ));
662 }
663 node.route = Some(r.clone());
664 }
665 Ok(root)
666 }
667
668 fn render(&self, indent: usize) -> String {
669 if let Some(route) = &self.route {
670 return render_route_method(route);
671 }
672 let pad = " ".repeat(indent);
673 let child_pad = " ".repeat(indent + 2);
674 let mut out = String::from("{\n");
675 for (name, child) in &self.children {
676 out.push_str(&format!(
677 "{child_pad}{}: {},\n",
678 safe_ts_property(name),
679 child.render(indent + 2)
680 ));
681 }
682 out.push_str(&format!("{pad}}}"));
683 out
684 }
685
686 fn render_js(&self, indent: usize) -> String {
687 if let Some(route) = &self.route {
688 return render_js_route_method(route);
689 }
690 let pad = " ".repeat(indent);
691 let child_pad = " ".repeat(indent + 2);
692 let mut out = String::from("{\n");
693 for (name, child) in &self.children {
694 out.push_str(&format!(
695 "{child_pad}{}: {},\n",
696 safe_ts_property(name),
697 child.render_js(indent + 2)
698 ));
699 }
700 out.push_str(&format!("{pad}}}"));
701 out
702 }
703}
704
705fn render_route_method(route: &NativeRoute) -> String {
706 let input_ts = route.input.as_deref().map(rust_to_ts);
707 let input_arg = input_ts
708 .as_ref()
709 .map(|ty| format!("input: {ty}"))
710 .unwrap_or_default();
711
712 match route.kind {
713 RouteKind::Call => {
714 let output = rust_to_ts(route.output.as_deref().unwrap_or("void"));
715 if route.input.is_some() {
716 format!(
717 "({input_arg}) => invoke<{output}, {}>(\"{}\", input)",
718 input_ts.unwrap(),
719 route.route
720 )
721 } else {
722 format!("() => invoke<{output}>(\"{}\")", route.route)
723 }
724 }
725 RouteKind::Stream => {
726 let event = rust_to_ts(route.event.as_deref().unwrap_or("unknown"));
727 let output = rust_to_ts(route.output.as_deref().unwrap_or("void"));
728 if route.input.is_some() {
729 format!(
730 "({input_arg}): NativeStream<{event}, {output}> => stream<{event}, {output}, {}>(\"{}\", input)",
731 input_ts.unwrap(),
732 route.route
733 )
734 } else {
735 format!(
736 "(): NativeStream<{event}, {output}> => stream<{event}, {output}>(\"{}\")",
737 route.route
738 )
739 }
740 }
741 RouteKind::Channel => {
742 let inbound = rust_to_ts(route.channel_in.as_deref().unwrap_or("unknown"));
743 let outbound = rust_to_ts(route.channel_out.as_deref().unwrap_or("unknown"));
744 if route.input.is_some() {
745 format!(
746 "({input_arg}): Promise<NativeChannel<{inbound}, {outbound}>> => channel<{inbound}, {outbound}>(\"{}\", input)",
747 route.route
748 )
749 } else {
750 format!(
751 "(): Promise<NativeChannel<{inbound}, {outbound}>> => channel<{inbound}, {outbound}>(\"{}\")",
752 route.route
753 )
754 }
755 }
756 }
757}
758
759fn render_js_route_method(route: &NativeRoute) -> String {
760 let parts = route
761 .route
762 .split('.')
763 .map(json_string)
764 .collect::<Vec<_>>()
765 .join(", ");
766 match route.kind {
767 RouteKind::Call => format!("call([{parts}])"),
768 RouteKind::Stream => format!("stream([{parts}])"),
769 RouteKind::Channel => format!("channel([{parts}])"),
770 }
771}
772
773fn type_string(ty: &syn::Type) -> String {
778 quote::quote!(#ty).to_string()
779}
780
781fn matching_angle(input: &str, start: usize) -> Option<usize> {
782 let mut depth = 0;
783 for (idx, ch) in input.char_indices().skip_while(|(idx, _)| *idx < start) {
784 match ch {
785 '<' => depth += 1,
786 '>' => {
787 depth -= 1;
788 if depth == 0 {
789 return Some(idx);
790 }
791 }
792 _ => {}
793 }
794 }
795 None
796}
797
798fn to_camel_case(name: &str) -> String {
799 let mut out = String::new();
800 let mut upper_next = false;
801 for ch in name.chars() {
802 if ch == '_' {
803 upper_next = true;
804 } else if upper_next {
805 out.extend(ch.to_uppercase());
806 upper_next = false;
807 } else {
808 out.push(ch);
809 }
810 }
811 out
812}
813
814fn safe_ts_property(name: &str) -> String {
815 if name
816 .chars()
817 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
818 && name
819 .chars()
820 .next()
821 .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
822 {
823 name.to_string()
824 } else {
825 json_string(name)
826 }
827}
828
829fn json_string(value: &str) -> String {
830 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 fn scan_source(source: &str) -> NativeManifest {
838 let ast = syn::parse_file(source).unwrap();
839 let mut manifest = NativeManifest {
840 routes: Vec::new(),
841 structs: BTreeMap::new(),
842 };
843 for item in &ast.items {
844 match item {
845 syn::Item::Fn(item_fn) => {
846 if let Some((route, kind)) = parse_attr(&item_fn.attrs) {
847 manifest
848 .routes
849 .push(extract_route_info(&route, kind, item_fn));
850 }
851 }
852 syn::Item::Struct(item_struct) => {
853 let fields = extract_struct_fields(item_struct);
854 if !fields.is_empty() {
855 manifest
856 .structs
857 .insert(item_struct.ident.to_string(), fields);
858 }
859 }
860 _ => {}
861 }
862 }
863 manifest.routes.sort_by(|a, b| a.route.cmp(&b.route));
864 manifest
865 }
866
867 #[test]
868 fn parses_native_call_and_private_struct() {
869 let manifest = scan_source(
870 r#"
871 struct OpenDeviceInput {
872 device_id: String,
873 retry_count: Option<u32>,
874 }
875
876 #[lingxia::native("device.open")]
877 pub async fn open_device(input: OpenDeviceInput) -> HostResult<()> { todo!() }
878 "#,
879 );
880 let generated = render(&manifest, OutputKind::TypeScriptModule).unwrap();
881 assert!(generated.contains("deviceId: string"));
882 assert!(generated.contains("retryCount?: number"));
883 assert!(generated.contains("invoke<void, OpenDeviceInput>"));
884 }
885
886 #[test]
887 fn route_names_do_not_select_stream_or_channel_mode() {
888 let manifest = scan_source(
889 r#"
890 #[lingxia::native("demo.streamInfo")]
891 pub fn stream_info() -> HostResult<String> { todo!() }
892
893 #[lingxia::native("demo.channelState")]
894 pub fn channel_state() -> HostResult<String> { todo!() }
895 "#,
896 );
897 assert_eq!(manifest.routes[0].kind, RouteKind::Call);
898 assert_eq!(manifest.routes[1].kind, RouteKind::Call);
899 }
900
901 #[test]
902 fn parses_stream_and_channel_context_types() {
903 let manifest = scan_source(
904 r#"
905 #[lingxia::native("downloads.watch", stream)]
906 pub async fn watch(ctx: crate::host::StreamContext<DownloadEvent, ()>) -> HostResult<()> { todo!() }
907
908 #[lingxia::native("editor.session", channel)]
909 pub async fn session(ctx: ChannelContext<EditorInput, EditorEvent>) -> HostResult<()> { todo!() }
910 "#,
911 );
912 let watch = manifest
913 .routes
914 .iter()
915 .find(|route| route.route == "downloads.watch")
916 .unwrap();
917 assert_eq!(watch.event.as_deref(), Some("DownloadEvent"));
918
919 let session = manifest
920 .routes
921 .iter()
922 .find(|route| route.route == "editor.session")
923 .unwrap();
924 assert_eq!(session.channel_in.as_deref(), Some("EditorInput"));
925 assert_eq!(session.channel_out.as_deref(), Some("EditorEvent"));
926 }
927
928 #[test]
929 fn generated_browser_js_uses_lingxia_bridge() {
930 let mut manifest = NativeManifest {
931 routes: Vec::new(),
932 structs: BTreeMap::new(),
933 };
934 manifest.routes.push(NativeRoute {
935 route: "downloads.list".to_string(),
936 kind: RouteKind::Call,
937 input: None,
938 output: Some("DownloadsSnapshot".to_string()),
939 event: None,
940 channel_in: None,
941 channel_out: None,
942 fn_ident: "list_downloads".to_string(),
943 });
944 let generated = render(&manifest, OutputKind::BrowserGlobalJs).unwrap();
945 assert!(generated.contains("global.native"));
946 assert!(generated.contains("LingXiaBridge"));
947 assert!(generated.contains("call([\"downloads\", \"list\"])"));
948 }
949
950 #[test]
951 fn rust_module_with_no_handlers_emits_empty_install() {
952 let manifest = NativeManifest {
953 routes: Vec::new(),
954 structs: BTreeMap::new(),
955 };
956 let generated = render(&manifest, OutputKind::RustModule).unwrap();
957 assert!(generated.contains("mod __lingxia_native"));
958 assert!(generated.contains("pub fn install()"));
959 assert!(!generated.contains("register_host_entry"));
960 }
961
962 #[test]
963 fn rust_module_emits_install_under_lingxia_namespace() {
964 let manifest = scan_source(
965 r#"
966 #[lingxia::native("device.open")]
967 pub fn open_device(input: OpenDeviceInput) -> HostResult<()> { todo!() }
968
969 #[lingxia::native("device.close")]
970 pub fn close_device() -> HostResult<()> { todo!() }
971 "#,
972 );
973 let generated = render(&manifest, OutputKind::RustModule).unwrap();
974 assert!(generated.contains("mod __lingxia_native"));
975 assert!(generated.contains("pub fn install()"));
976 assert!(
977 generated.contains("::lingxia::host::register_host_entry(crate::open_device_host())")
978 );
979 assert!(
980 generated.contains("::lingxia::host::register_host_entry(crate::close_device_host())")
981 );
982 }
983
984 #[test]
985 fn detects_route_prefix_conflicts() {
986 let routes = vec![
987 NativeRoute {
988 route: "a.b".to_string(),
989 kind: RouteKind::Call,
990 input: None,
991 output: None,
992 event: None,
993 channel_in: None,
994 channel_out: None,
995 fn_ident: "a_b".to_string(),
996 },
997 NativeRoute {
998 route: "a.b.c".to_string(),
999 kind: RouteKind::Call,
1000 input: None,
1001 output: None,
1002 event: None,
1003 channel_in: None,
1004 channel_out: None,
1005 fn_ident: "a_b_c".to_string(),
1006 },
1007 ];
1008 assert!(RouteNode::build(&routes).is_err());
1009 }
1010}