use anyhow::{anyhow, bail, Context, Result};
use clap::Args;
use std::path::{Path, PathBuf};
#[derive(Args, Debug)]
pub struct NewModuleArgs {
pub name: String,
#[arg(long)]
pub path: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = ModuleShape::ViewBearing)]
pub shape: ModuleShape,
}
#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum ModuleShape {
#[value(name = "view-bearing")]
ViewBearing,
#[value(name = "function-only")]
FunctionOnly,
}
pub fn run(args: NewModuleArgs) -> Result<()> {
validate_crate_name(&args.name)?;
let parent = args.path.unwrap_or_else(|| PathBuf::from("."));
let target_dir = parent.join(&args.name);
if target_dir.exists() {
bail!(
"{}: directory already exists. Pick a different name or remove it.",
target_dir.display(),
);
}
let tag = pascal_case_tag(&args.name);
let spm = crate_to_spm_target(&args.name);
let ns = args.name.replace('-', "_");
let ident = args
.name
.replace('-', "_")
.trim_start_matches("whisker_")
.to_string();
let module_class = format!("{tag}Module");
let view_class = format!("{tag}View");
let v = Vars {
crate_name: &args.name,
tag: &tag,
spm: &spm,
ns: &ns,
ident: &ident,
module_class: &module_class,
view_class: &view_class,
};
let ios_src = format!("ios/Sources/{spm}");
let android_src = format!("android/src/main/kotlin/rs/whisker/modules/{ns}");
std::fs::create_dir_all(target_dir.join(&ios_src))
.with_context(|| format!("create {}/{ios_src}", target_dir.display()))?;
std::fs::create_dir_all(target_dir.join(&android_src))
.with_context(|| format!("create {}/{android_src}", target_dir.display()))?;
write(&target_dir, "Cargo.toml", &cargo_toml(&v))?;
write(&target_dir, "README.md", &readme(&v))?;
write(&target_dir, "Package.swift", &package_swift(&v))?;
write(&target_dir, "build.gradle.kts", &build_gradle(&v))?;
match args.shape {
ModuleShape::ViewBearing => {
write(&target_dir, "src/lib.rs", &lib_rs_view(&v))?;
write(
&target_dir,
&format!("{ios_src}/{module_class}.swift"),
&swift_view_module(&v),
)?;
write(
&target_dir,
&format!("{ios_src}/{view_class}.swift"),
&swift_view(&v),
)?;
write(
&target_dir,
&format!("{android_src}/{module_class}.kt"),
&kotlin_view_module(&v),
)?;
write(
&target_dir,
&format!("{android_src}/{view_class}.kt"),
&kotlin_view(&v),
)?;
}
ModuleShape::FunctionOnly => {
write(&target_dir, "src/lib.rs", &lib_rs_module(&v))?;
write(
&target_dir,
&format!("{ios_src}/{module_class}.swift"),
&swift_function_module(&v),
)?;
write(
&target_dir,
&format!("{android_src}/{module_class}.kt"),
&kotlin_function_module(&v),
)?;
}
}
eprintln!(
"Created Whisker module skeleton at {}\n\
\n\
Next steps:\n \
1. cd {}\n \
2. Implement the platform-side logic in ios/ and android/.\n \
3. From your Whisker app: `cargo add --path {}` (or publish to crates.io).\n \
4. See docs/module-author-guide.md for the full reference.",
target_dir.display(),
target_dir.display(),
target_dir.display(),
);
Ok(())
}
struct Vars<'a> {
crate_name: &'a str,
tag: &'a str,
spm: &'a str,
ns: &'a str,
ident: &'a str,
module_class: &'a str,
view_class: &'a str,
}
fn write(root: &Path, rel: &str, content: &str) -> Result<()> {
let path = root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
fn cargo_toml(v: &Vars) -> String {
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Whisker module — short tagline shown on crates.io."
include = [
"Cargo.toml",
"Package.swift",
"build.gradle.kts",
"src/lib.rs",
"android/**/*.kt",
"ios/**/*.swift",
"README.md",
]
[lib]
crate-type = ["rlib"]
# Module-system opt-in marker — the bare table identifies this cargo
# crate as a Whisker module, so `whisker-build` wires its `android/`
# Gradle subproject + `ios/` SwiftPM package into the host build.
[package.metadata.whisker]
[dependencies]
# The umbrella `whisker` crate. The proc macros' emit paths
# (::whisker::ElementRef, ::whisker::platform_module::WhiskerValue, ...)
# resolve under the `whisker` name — the same dep app crates use.
whisker = "0.1"
"#,
name = v.crate_name,
)
}
fn readme(v: &Vars) -> String {
format!(
r#"# {name}
A Whisker module — registers the `{name}:{tag}` element under Lynx
and exposes `{tag}` for use in Whisker app `render!` trees.
## Usage
```toml
[dependencies]
{name} = "0.1"
```
```rust
use whisker::prelude::*;
use {ident}::*;
#[whisker::main]
fn app() -> Element {{
render! {{
{tag}()
}}
}}
```
See [the Whisker Module Author Guide](https://github.com/whiskerrs/whisker/blob/main/docs/module-author-guide.md)
for the full reference.
"#,
name = v.crate_name,
tag = v.tag,
ident = v.ident,
)
}
fn package_swift(v: &Vars) -> String {
format!(
r#"// swift-tools-version:5.9
//
// SwiftPM manifest for the `{name}` module's iOS half. The consumer
// app's `whisker-build`-generated aggregator depends on the library
// product below via `.product(name: "{spm}", package: "{name}")`.
//
// Package.swift lives at the package root (SwiftPM requires it
// there); the Swift sources live under the `ios/` subdir alongside
// `android/` + `src/`.
import PackageDescription
// whisker-build injects the absolute location of Whisker's iOS
// runtime + macros packages via these env vars, so this module
// resolves them wherever it lives — in a whisker project, or unpacked
// from the cargo registry. A Whisker module is only ever built through
// `whisker run` (which set these), never standalone.
guard let whiskerRuntimePath = Context.environment["WHISKER_IOS_RUNTIME"],
let whiskerMacrosPath = Context.environment["WHISKER_IOS_MACROS"]
else {{
fatalError("""
WHISKER_IOS_RUNTIME / WHISKER_IOS_MACROS not set. Build this Whisker \
module through `whisker run`, which inject these paths.
""")
}}
let package = Package(
name: "{name}",
platforms: [.iOS(.v13), .macOS(.v13)],
products: [
.library(name: "{spm}", targets: ["{spm}"]),
],
dependencies: [
.package(name: "macros", path: whiskerMacrosPath),
.package(name: "WhiskerRuntime", path: whiskerRuntimePath),
],
targets: [
.target(
name: "{spm}",
dependencies: [
.product(name: "WhiskerModule", package: "WhiskerRuntime"),
],
path: "ios/Sources/{spm}",
plugins: [
.plugin(name: "WhiskerModuleCodegenPlugin", package: "macros"),
]
),
]
)
"#,
name = v.crate_name,
spm = v.spm,
)
}
fn build_gradle(v: &Vars) -> String {
format!(
r#"// Gradle subproject for the `{name}` Whisker module on Android.
// Wired into the consumer app's settings.gradle.kts by whisker-build.
// build.gradle.kts sits at the package root, alongside Package.swift
// + Cargo.toml; the Kotlin source set points at the `android/` subdir.
plugins {{
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
}}
android {{
namespace = "rs.whisker.modules.{ns}"
compileSdk = 34
defaultConfig {{
minSdk = 21
}}
compileOptions {{
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}}
kotlinOptions {{
jvmTarget = "17"
}}
sourceSets {{
getByName("main") {{
kotlin.srcDirs("android/src/main/kotlin")
}}
}}
}}
ksp {{
arg("whisker.moduleName", "{spm}")
arg("whisker.crateName", "{name}")
}}
dependencies {{
// Single Whisker runtime dep. ksp(rs.whisker:ksp) stays
// separate because it's a build-time processor, not on the
// runtime classpath. The KSP processor discovers Module
// subclasses by inheritance (no marker annotation needed).
implementation(project(":module"))
ksp("rs.whisker:ksp")
}}
"#,
name = v.crate_name,
ns = v.ns,
spm = v.spm,
)
}
fn lib_rs_view(v: &Vars) -> String {
format!(
r#"//! `{name}` — Whisker view-bearing module.
//!
//! Registers a Lynx element under `{name}:{tag}` and exposes the
//! `{tag}` symbol for use inside `render!`. Platform-side classes
//! live under `ios/` and `android/`.
use whisker::Signal;
/// View-bearing element. The Lynx tag the bridge registers against
/// is `{name}:{tag}` — the crate name namespace is auto-prepended by
/// `#[whisker::module_component]`. Imperative methods on a mounted
/// instance go through an `ElementRef` (the `ref:` prop) — wrap one
/// in a typed `{tag}Handle` struct for the public API.
#[whisker::module_component("{tag}")]
pub fn {ident}(style: Signal<String>) {{}}
"#,
name = v.crate_name,
tag = v.tag,
ident = v.ident,
)
}
fn lib_rs_module(v: &Vars) -> String {
format!(
r#"//! `{name}` — Whisker function-only platform module.
//!
//! Exposes typed Rust -> Kotlin/Swift function calls without
//! rendering UI. Platform-side classes live under `ios/` and
//! `android/`.
use whisker::platform_module::{{WhiskerModuleError, WhiskerValue}};
/// Typed Rust API for the `Whisker{tag}` platform module.
///
/// Hand-written wrapper over the framework primitive: each method
/// builds the raw `Vec<WhiskerValue>` arg list, dispatches via
/// `whisker::module!("Whisker{tag}").invoke(method, args)`, and lifts
/// the returned `WhiskerValue` into a typed `Result`. The `module!`
/// name MUST match the `Name("...")` in the platform-side
/// `definition()`; `module!` auto-prepends this crate's name so two
/// crates can ship same-named modules without colliding.
pub struct Whisker{tag};
impl Whisker{tag} {{
pub fn placeholder() -> Result<(), WhiskerModuleError> {{
// Build args, dispatch, lift the WhiskerValue into a typed result.
match whisker::module!("Whisker{tag}").invoke("_placeholder", vec![]) {{
WhiskerValue::Error(msg) => Err(WhiskerModuleError(msg)),
_ => Ok(()),
}}
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
)
}
fn swift_view_module(v: &Vars) -> String {
format!(
r#"// `{module_class}` — iOS side of the `{name}:{tag}` Whisker module.
//
// Declares the Lynx element `{name}:{tag}` via the ModuleDefinition
// DSL. Subclassing `Module` is the registration signal — the SwiftPM
// codegen plugin walks every `Module` subclass and emits the Lynx
// behavior registration. The `{view_class}` Lynx UI subclass lives
// in `{view_class}.swift`.
import WhiskerModule // Module, ModuleDefinition, DSL
public final class {module_class}: Module {{
public override func definition() -> ModuleDefinition {{
ModuleDefinition {{
Name("{tag}")
View({view_class}.self) {{
// Declare Prop / Function entries here, e.g.:
// Prop("title") {{ (view: {view_class}, value: String) in
// view.setTitle(value)
// }}
// Function("focus") {{ (view: {view_class}) in view.focus() }}
}}
}}
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
module_class = v.module_class,
view_class = v.view_class,
)
}
fn swift_view(v: &Vars) -> String {
format!(
r#"// `{view_class}` — the Lynx UI subclass backing `{name}:{tag}`.
// Instantiated by Lynx via the behavior `{module_class}.definition()`
// registers. `@objc({view_class})` pins the Obj-C class name so the
// codegen plugin's `NSClassFromString` lookup resolves it.
import UIKit
import WhiskerModule
@objc({view_class})
public final class {view_class}: WhiskerUI<UIView> {{
@objc public override func createView() -> UIView {{
let v = UIView()
v.backgroundColor = .systemPink
return v
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
module_class = v.module_class,
view_class = v.view_class,
)
}
fn kotlin_view_module(v: &Vars) -> String {
format!(
r#"// `{module_class}` -- Android side of the `{name}:{tag}` Whisker module.
//
// Subclassing `Module` is the registration signal — the KSP processor
// walks every concrete subclass and emits the Lynx behavior
// registration. The `{view_class}` Lynx UI subclass lives in
// `{view_class}.kt`.
//
// Note the explicit `import rs.whisker.runtime.Module` — without it
// the unqualified `Module` resolves to `java.lang.Module` (a Kotlin
// JVM default import).
package rs.whisker.modules.{ns}
import rs.whisker.runtime.Module
import rs.whisker.runtime.ModuleDefinition
class {module_class} : Module() {{
override fun definition() = ModuleDefinition {{
Name("{tag}")
View({view_class}::class.java) {{
// Declare Prop / Function entries here, e.g.:
// Prop("title") {{ view: {view_class}, value: String ->
// view.setTitle(value)
// }}
// Function("focus") {{ view: {view_class} -> view.focus() }}
}}
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
ns = v.ns,
module_class = v.module_class,
view_class = v.view_class,
)
}
fn kotlin_view(v: &Vars) -> String {
format!(
r#"// `{view_class}` -- the Lynx UI subclass backing `{name}:{tag}`.
// Instantiated by the Lynx behavior `{module_class}.definition()`
// registers. The single-arg `(WhiskerContext)` constructor matches
// the convention the KSP registration code expects.
package rs.whisker.modules.{ns}
import android.content.Context
import android.graphics.Color
import android.view.View
import rs.whisker.runtime.WhiskerContext
import rs.whisker.runtime.WhiskerUI
open class {view_class}(context: WhiskerContext) : WhiskerUI<View>(context) {{
override fun createView(context: Context): View {{
val v = View(context)
v.setBackgroundColor(Color.argb(0xff, 0xff, 0x80, 0xa0))
return v
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
ns = v.ns,
module_class = v.module_class,
view_class = v.view_class,
)
}
fn swift_function_module(v: &Vars) -> String {
format!(
r#"// `{module_class}` — iOS side of the `{name}` Whisker function-only module.
//
// A view-less DSL module: `definition()` has no `View(...)` block,
// just module-level `Function`s. Subclassing `Module` is the
// registration signal — the SwiftPM codegen plugin emits a dispatch
// shim registered under the `Name("...")`, so
// `Whisker{tag}::placeholder()` on the Rust side routes here.
import WhiskerModule // Module, ModuleDefinition, DSL
public final class {module_class}: Module {{
public override func definition() -> ModuleDefinition {{
ModuleDefinition {{
// The Name MUST match the Rust sys trait's
// `#[whisker::platform_module(name = "...")]`.
Name("Whisker{tag}")
Function("_placeholder") {{
// TODO: implement the function the Rust sys trait declares.
}}
}}
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
module_class = v.module_class,
)
}
fn kotlin_function_module(v: &Vars) -> String {
format!(
r#"// `{module_class}` -- Android side of the `{name}` Whisker function-only module.
//
// A view-less DSL module: module-level `Function`s, no `View(...)`.
// Subclassing `Module` is the registration signal. See the note in
// the view-bearing template re: the explicit `Module` import.
package rs.whisker.modules.{ns}
import rs.whisker.runtime.Module
import rs.whisker.runtime.ModuleDefinition
class {module_class} : Module() {{
override fun definition() = ModuleDefinition {{
// The Name MUST match the Rust sys trait's
// `#[whisker::platform_module(name = "...")]`.
Name("Whisker{tag}")
Function("_placeholder") {{
// TODO: implement the function the Rust sys trait declares.
}}
}}
}}
"#,
name = v.crate_name,
tag = v.tag,
ns = v.ns,
module_class = v.module_class,
)
}
fn validate_crate_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("crate name must not be empty");
}
let first = name.chars().next().unwrap();
if !first.is_ascii_alphabetic() {
bail!(
"crate name must start with a letter, got {first:?}. Try \
`whisker-{name}` instead."
);
}
for ch in name.chars() {
if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
return Err(anyhow!(
"crate name {name:?} contains invalid character {ch:?}. \
Use only ASCII letters / digits / `-` / `_`."
));
}
}
Ok(())
}
fn pascal_case_tag(crate_name: &str) -> String {
let stripped = crate_name.strip_prefix("whisker-").unwrap_or(crate_name);
let mut out = String::new();
let mut upper = true;
for ch in stripped.chars() {
if ch == '-' || ch == '_' {
upper = true;
} else if upper {
out.extend(ch.to_uppercase());
upper = false;
} else {
out.push(ch);
}
}
out
}
fn crate_to_spm_target(crate_name: &str) -> String {
let mut out = String::new();
let mut upper = true;
for ch in crate_name.chars() {
if ch == '-' || ch == '_' {
upper = true;
} else if upper {
out.extend(ch.to_uppercase());
upper = false;
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pascal_strips_whisker_prefix() {
assert_eq!(pascal_case_tag("whisker-foo"), "Foo");
assert_eq!(pascal_case_tag("whisker-blur-view"), "BlurView");
}
#[test]
fn pascal_keeps_full_name_when_no_whisker_prefix() {
assert_eq!(pascal_case_tag("foo-bar"), "FooBar");
}
#[test]
fn spm_target_pascals_full_crate_name() {
assert_eq!(crate_to_spm_target("whisker-foo"), "WhiskerFoo");
assert_eq!(crate_to_spm_target("whisker-blur-view"), "WhiskerBlurView");
}
#[test]
fn validate_rejects_invalid() {
assert!(validate_crate_name("").is_err());
assert!(validate_crate_name("1foo").is_err());
assert!(validate_crate_name("whisker foo").is_err());
assert!(validate_crate_name("whisker-foo").is_ok());
assert!(validate_crate_name("whisker_foo").is_ok());
}
}