aurorality-cli 0.1.0

CLI for aurorality: dev server, build, and project scaffolding
//! `aurorality bindgen` — generate typed Swift plugin wrappers from JS export signatures.
//!
//! Scans a directory of `.js` files, extracts top-level function names via regex,
//! and emits a Swift file per plugin with methods conforming to `AurorPlugin`-style calling.

use std::path::Path;

use anyhow::{Context, Result};

pub fn run(input_dir: &Path, output_dir: &Path) -> Result<()> {
    std::fs::create_dir_all(output_dir)?;
    let mut count = 0;

    for entry in std::fs::read_dir(input_dir)
        .with_context(|| format!("cannot read input dir: {}", input_dir.display()))?
        .flatten()
    {
        let path = entry.path();
        if path.extension().is_some_and(|e| e == "js") {
            let code = std::fs::read_to_string(&path)
                .with_context(|| format!("read {}", path.display()))?;
            let methods = extract_fn_names(&code);
            let plugin_id = path
                .file_stem()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string();

            let swift = generate_swift_plugin(&plugin_id, &methods);
            let struct_name = pascal_case(&plugin_id);
            let out = output_dir.join(format!("{struct_name}Plugin.swift"));
            std::fs::write(&out, swift).with_context(|| format!("write {}", out.display()))?;
            println!(
                "  {} ({} methods)  →  {}",
                plugin_id,
                methods.len(),
                out.display()
            );
            count += 1;
        }
    }

    println!("generated {count} Swift plugin wrapper(s)");
    Ok(())
}

/// Extract top-level function names from JS source.
/// Matches: `export function foo(` or `function foo(` at start of line.
pub fn extract_fn_names(code: &str) -> Vec<String> {
    let mut names = Vec::new();
    for line in code.lines() {
        let trimmed = line.trim();
        // Match: [export] function name(
        let rest = if let Some(r) = trimmed.strip_prefix("export function ") {
            r
        } else if let Some(r) = trimmed.strip_prefix("function ") {
            r
        } else {
            continue;
        };
        // Extract identifier before '('
        let name: String = rest
            .chars()
            .take_while(|c| c.is_alphanumeric() || *c == '_')
            .collect();
        if !name.is_empty() {
            names.push(name);
        }
    }
    names
}

fn generate_swift_plugin(plugin_id: &str, methods: &[String]) -> String {
    let struct_name = pascal_case(plugin_id);
    let mut out = String::new();

    out.push_str("// AUTO-GENERATED by `aurorality bindgen` — do not edit\n");
    out.push_str("// Source: ");
    out.push_str(plugin_id);
    out.push_str(".js\n\n");
    out.push_str("import Foundation\n\n");
    out.push_str("/// Swift wrapper for the `");
    out.push_str(plugin_id);
    out.push_str("` JavaScript plugin.\n");
    out.push_str("/// Load the plugin first:\n");
    out.push_str("///   try loadJsPlugin(id: \"");
    out.push_str(plugin_id);
    out.push_str("\", resource: \"");
    out.push_str(plugin_id);
    out.push_str("\")\n");
    out.push_str("public struct ");
    out.push_str(&struct_name);
    out.push_str("Plugin {\n");
    out.push_str("    public static let pluginId = \"");
    out.push_str(plugin_id);
    out.push_str("\"\n\n");
    out.push_str("    private let bridge: AurorBridge\n\n");
    out.push_str("    public init(bridge: AurorBridge) {\n");
    out.push_str("        self.bridge = bridge\n");
    out.push_str("    }\n");

    for method in methods {
        out.push('\n');
        out.push_str("    /// Call the `");
        out.push_str(method);
        out.push_str("` function in `");
        out.push_str(plugin_id);
        out.push_str(".js`.\n");
        out.push_str("    /// `payload` must be a valid JSON object string, e.g. `\"{\\\"count\\\": 1}\"`.\n");
        out.push_str("    public func ");
        out.push_str(method);
        out.push_str("(_ payload: String = \"{}\") throws -> String {\n");
        out.push_str("        try bridge.invoke(\n");
        out.push_str("            pluginId: Self.pluginId,\n");
        out.push_str("            method: \"");
        out.push_str(method);
        out.push_str("\",\n");
        out.push_str("            payload: payload\n");
        out.push_str("        )\n");
        out.push_str("    }\n");
    }

    out.push_str("}\n");
    out
}

/// Convert `kebab-case`, `snake_case`, or `camelCase` to `PascalCase`.
pub fn pascal_case(s: &str) -> String {
    let mut result = String::new();
    let mut cap_next = true;
    for ch in s.chars() {
        if ch == '-' || ch == '_' {
            cap_next = true;
        } else if cap_next {
            result.extend(ch.to_uppercase());
            cap_next = false;
        } else {
            result.push(ch);
        }
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extract_named_fns() {
        let code = r#"
export function increment(payload) {
    return { count: payload.count + 1 };
}
function decrement(payload) {
    return { count: payload.count - 1 };
}
const helper = () => {};
"#;
        let names = extract_fn_names(code);
        assert_eq!(names, vec!["increment", "decrement"]);
    }

    #[test]
    fn pascal_cases() {
        assert_eq!(pascal_case("counter"), "Counter");
        assert_eq!(pascal_case("my-plugin"), "MyPlugin");
        assert_eq!(pascal_case("my_plugin"), "MyPlugin");
    }
}