Skip to main content

lingxia_native_codegen/
lib.rs

1//! 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 emits one of:
5//!
6//! - `.ts` — TypeScript client with typed `invoke` / `stream` / `channel` bindings
7//! - `.js` — browser global JS client
8//! - `.rs` — Rust auto-register module (`mod __lingxia_native { pub fn install() }`)
9//!
10//! Intended as a build-dependency so `build.rs` can produce the artifacts during
11//! `cargo build`, before the lxapp is assembled.
12
13use 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
20// ---------------------------------------------------------------------------
21// Public API
22// ---------------------------------------------------------------------------
23
24/// Emit the TypeScript (or browser-global JS) client for every `#[native]`
25/// handler discovered under `rust_dir`. Output format is chosen by the
26/// extension of `out` (`.ts` → TS module, `.js` → browser global).
27///
28/// If no handlers are found the output file is removed.
29pub 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
38/// Emit the Rust auto-register module that wraps every discovered handler's
39/// `register_host_entry(...)` call inside `mod __lingxia_native { pub fn install() }`.
40/// `out` must end in `.rs`.
41///
42/// Always writes the file (an empty `install()` body for handler-less crates)
43/// so the consumer's `include!` keeps compiling.
44pub 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    // For TS / JS clients, "no routes" means the consumer doesn't need a
59    // client file at all — we delete to avoid stale generated artifacts.
60    // The Rust registry's consumer `include!`s it, so we always emit.
61    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    /// Rust module containing `pub fn register_all()` that calls
84    /// `register_host_entry(...)` for every discovered `#[native]`.
85    /// Emit by passing a `.rs` output path.
86    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// ---------------------------------------------------------------------------
98// Data model
99// ---------------------------------------------------------------------------
100
101#[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    /// Original Rust function name — used by the Rust-output mode to emit
118    /// `register_host_entry(crate::{fn_ident}_host())` calls.
119    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
135// ---------------------------------------------------------------------------
136// Scanner (syn-based)
137// ---------------------------------------------------------------------------
138
139fn 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    // Collision check.
173    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
197/// Match `#[lingxia::native("route")]` or `#[native("route")]` with optional flags.
198fn 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
378// ---------------------------------------------------------------------------
379// TypeScript rendering
380// ---------------------------------------------------------------------------
381
382fn 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
390/// Emits a Rust file that, when `include!`d from the consumer crate root,
391/// defines a private module `__lingxia_native` with `pub fn install()` that
392/// calls `register_host_entry` for every discovered handler. Intended to be
393/// invoked from the consumer's `HostAddon::install_logic_extensions` so
394/// adding a new `#[native]` doesn't require a paired manual registration.
395///
396/// Wrapping the generated code in a `__lingxia_native` submodule keeps the
397/// crate root clean — the double-underscore prefix signals "generated /
398/// internal" by convention, and `install()` is unambiguous in that
399/// namespace ("install LingXia native handlers").
400///
401/// Generated calls use `crate::{fn_ident}_host()` — handlers must
402/// therefore be accessible at the consumer crate root (re-export via
403/// `pub use` if they live in submodules).
404fn 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// ---------------------------------------------------------------------------
631// Route tree → nested TypeScript object literal
632// ---------------------------------------------------------------------------
633
634#[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
773// ---------------------------------------------------------------------------
774// Helpers
775// ---------------------------------------------------------------------------
776
777fn 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}