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