Skip to main content

lingxia_native_codegen/
lib.rs

1//! TypeScript code generation for `#[lingxia::native]` host handlers.
2//!
3//! Scans Rust source files for `#[lingxia::native("route")]` / `#[native("route")]`
4//! function attributes and `pub struct` definitions, then generates a `.ts` module
5//! with typed `invoke` / `stream` / `channel` bindings.
6//!
7//! Intended as a build-dependency so `build.rs` can produce the types during
8//! `cargo build`, before the lxapp is assembled.
9
10use 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
17// ---------------------------------------------------------------------------
18// Public API
19// ---------------------------------------------------------------------------
20
21/// Scan `rust_dir` (recursively) for `#[lingxia::native]` / `#[native]` handlers
22/// and struct definitions, then write a native client to `out_path`.
23///
24/// If no native handlers are found the output file is removed (clean slate).
25pub fn generate(rust_dir: &Path, out: &Path) -> Result<()> {
26    generate_native_client_from_paths(rust_dir, out)
27}
28
29/// Compatibility entry point used by native build scripts.
30pub 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// ---------------------------------------------------------------------------
70// Data model
71// ---------------------------------------------------------------------------
72
73#[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
104// ---------------------------------------------------------------------------
105// Scanner (syn-based)
106// ---------------------------------------------------------------------------
107
108fn 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    // Collision check.
142    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
166/// Match `#[lingxia::native("route")]` or `#[native("route")]` with optional flags.
167fn 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
346// ---------------------------------------------------------------------------
347// TypeScript rendering
348// ---------------------------------------------------------------------------
349
350fn 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// ---------------------------------------------------------------------------
562// Route tree → nested TypeScript object literal
563// ---------------------------------------------------------------------------
564
565#[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
704// ---------------------------------------------------------------------------
705// Helpers
706// ---------------------------------------------------------------------------
707
708fn 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}