1use anyhow::{anyhow, bail, Context, Result};
32use clap::Args;
33use std::path::{Path, PathBuf};
34
35#[derive(Args, Debug)]
37pub struct NewModuleArgs {
38 pub name: String,
43
44 #[arg(long)]
47 pub path: Option<PathBuf>,
48
49 #[arg(long, value_enum, default_value_t = ModuleShape::ViewBearing)]
57 pub shape: ModuleShape,
58}
59
60#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
61pub enum ModuleShape {
62 #[value(name = "view-bearing")]
65 ViewBearing,
66 #[value(name = "function-only")]
68 FunctionOnly,
69}
70
71pub fn run(args: NewModuleArgs) -> Result<()> {
72 validate_crate_name(&args.name)?;
73 let parent = args.path.unwrap_or_else(|| PathBuf::from("."));
74 let target_dir = parent.join(&args.name);
75 if target_dir.exists() {
76 bail!(
77 "{}: directory already exists. Pick a different name or remove it.",
78 target_dir.display(),
79 );
80 }
81
82 let tag = pascal_case_tag(&args.name);
83 let spm = crate_to_spm_target(&args.name);
84 let ns = args.name.replace('-', "_");
85 let ident = args
86 .name
87 .replace('-', "_")
88 .trim_start_matches("whisker_")
89 .to_string();
90 let module_class = format!("{tag}Module");
91 let view_class = format!("{tag}View");
92
93 let v = Vars {
94 crate_name: &args.name,
95 tag: &tag,
96 spm: &spm,
97 ns: &ns,
98 ident: &ident,
99 module_class: &module_class,
100 view_class: &view_class,
101 };
102
103 let ios_src = format!("ios/Sources/{spm}");
106 let android_src = format!("android/src/main/kotlin/rs/whisker/modules/{ns}");
107 std::fs::create_dir_all(target_dir.join(&ios_src))
108 .with_context(|| format!("create {}/{ios_src}", target_dir.display()))?;
109 std::fs::create_dir_all(target_dir.join(&android_src))
110 .with_context(|| format!("create {}/{android_src}", target_dir.display()))?;
111
112 write(&target_dir, "Cargo.toml", &cargo_toml(&v))?;
113 write(&target_dir, "README.md", &readme(&v))?;
114 write(&target_dir, "Package.swift", &package_swift(&v))?;
115 write(&target_dir, "build.gradle.kts", &build_gradle(&v))?;
116
117 match args.shape {
118 ModuleShape::ViewBearing => {
119 write(&target_dir, "src/lib.rs", &lib_rs_view(&v))?;
120 write(
121 &target_dir,
122 &format!("{ios_src}/{module_class}.swift"),
123 &swift_view_module(&v),
124 )?;
125 write(
126 &target_dir,
127 &format!("{ios_src}/{view_class}.swift"),
128 &swift_view(&v),
129 )?;
130 write(
131 &target_dir,
132 &format!("{android_src}/{module_class}.kt"),
133 &kotlin_view_module(&v),
134 )?;
135 write(
136 &target_dir,
137 &format!("{android_src}/{view_class}.kt"),
138 &kotlin_view(&v),
139 )?;
140 }
141 ModuleShape::FunctionOnly => {
142 write(&target_dir, "src/lib.rs", &lib_rs_module(&v))?;
143 write(
144 &target_dir,
145 &format!("{ios_src}/{module_class}.swift"),
146 &swift_function_module(&v),
147 )?;
148 write(
149 &target_dir,
150 &format!("{android_src}/{module_class}.kt"),
151 &kotlin_function_module(&v),
152 )?;
153 }
154 }
155
156 eprintln!(
157 "Created Whisker module skeleton at {}\n\
158 \n\
159 Next steps:\n \
160 1. cd {}\n \
161 2. Implement the platform-side logic in ios/ and android/.\n \
162 3. From your Whisker app: `cargo add --path {}` (or publish to crates.io).\n \
163 4. See https://whisker.rs/docs/authoring-a-module for the full reference.",
164 target_dir.display(),
165 target_dir.display(),
166 target_dir.display(),
167 );
168 Ok(())
169}
170
171struct Vars<'a> {
176 crate_name: &'a str,
178 tag: &'a str,
180 spm: &'a str,
183 ns: &'a str,
186 ident: &'a str,
189 module_class: &'a str,
191 view_class: &'a str,
193}
194
195fn write(root: &Path, rel: &str, content: &str) -> Result<()> {
196 let path = root.join(rel);
197 if let Some(parent) = path.parent() {
198 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
199 }
200 std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
201 Ok(())
202}
203
204fn whisker_dep_version() -> String {
211 let v = env!("CARGO_PKG_VERSION");
212 let mut parts = v.split('.');
213 let major = parts.next().unwrap_or("0");
214 let minor = parts.next().unwrap_or("0");
215 format!("{major}.{minor}")
216}
217
218fn cargo_toml(v: &Vars) -> String {
219 format!(
220 r#"[package]
221name = "{name}"
222version = "0.1.0"
223edition = "2021"
224license = "MIT OR Apache-2.0"
225description = "Whisker module — short tagline shown on crates.io."
226
227include = [
228 "Cargo.toml",
229 "Package.swift",
230 "build.gradle.kts",
231 "src/lib.rs",
232 "android/**/*.kt",
233 "ios/**/*.swift",
234 "README.md",
235]
236
237[lib]
238crate-type = ["rlib"]
239
240# Module-system opt-in marker — the bare table identifies this cargo
241# crate as a Whisker module, so `whisker-build` wires its `android/`
242# Gradle subproject + `ios/` SwiftPM package into the host build.
243[package.metadata.whisker]
244
245[dependencies]
246# The umbrella `whisker` crate. The proc macros' emit paths
247# (::whisker::ElementRef, ::whisker::platform_module::WhiskerValue, ...)
248# resolve under the `whisker` name — the same dep app crates use.
249whisker = "{dep_version}"
250"#,
251 name = v.crate_name,
252 dep_version = whisker_dep_version(),
253 )
254}
255
256fn readme(v: &Vars) -> String {
257 format!(
258 r#"# {name}
259
260A Whisker module — registers the `{name}:{tag}` element under Lynx
261and exposes `{tag}` for use in Whisker app `render!` trees.
262
263## Usage
264
265```toml
266[dependencies]
267{name} = "{dep_version}"
268```
269
270```rust
271use whisker::prelude::*;
272use {ident}::*;
273
274#[whisker::main]
275fn app() -> Element {{
276 render! {{
277 {tag}()
278 }}
279}}
280```
281
282See [the Whisker Module Author Guide](https://whisker.rs/docs/authoring-a-module)
283for the full reference.
284"#,
285 name = v.crate_name,
286 tag = v.tag,
287 ident = v.ident,
288 dep_version = whisker_dep_version(),
289 )
290}
291
292fn package_swift(v: &Vars) -> String {
293 format!(
294 r#"// swift-tools-version:5.9
295//
296// SwiftPM manifest for the `{name}` module's iOS half. The consumer
297// app's `whisker-build`-generated aggregator depends on the library
298// product below via `.product(name: "{spm}", package: "{name}")`.
299//
300// Package.swift lives at the package root (SwiftPM requires it
301// there); the Swift sources live under the `ios/` subdir alongside
302// `android/` + `src/`.
303//
304// The module resolves Whisker's iOS runtime + macros via the published
305// `whisker` SwiftPM package (the same remote-git dependency every
306// first-party module uses) — `WhiskerModule` re-exports Lynx, and
307// `WhiskerRuntime` pulls in the `WhiskerView` / driver symbols. The
308// `WhiskerModuleCodegenPlugin` build-tool plugin walks `Module`
309// subclasses at build time and emits the Lynx registration.
310
311import PackageDescription
312
313let package = Package(
314 name: "{name}",
315 platforms: [.iOS(.v13), .macOS(.v13)],
316 products: [
317 .library(name: "{spm}", targets: ["{spm}"]),
318 ],
319 dependencies: [
320 .package(url: "https://github.com/whiskerrs/whisker.git", exact: "{ios_tag}"),
321 ],
322 targets: [
323 .target(
324 name: "{spm}",
325 dependencies: [
326 .product(name: "WhiskerModule", package: "whisker"),
327 .product(name: "WhiskerRuntime", package: "whisker"),
328 ],
329 path: "ios/Sources/{spm}",
330 plugins: [
331 .plugin(name: "WhiskerModuleCodegenPlugin", package: "whisker"),
332 ]
333 ),
334 ]
335)
336"#,
337 name = v.crate_name,
338 spm = v.spm,
339 ios_tag = WHISKER_IOS_SPM_TAG,
340 )
341}
342
343const WHISKER_IOS_SPM_TAG: &str = "0.1.0";
349
350fn build_gradle(v: &Vars) -> String {
351 format!(
352 r#"// Gradle subproject for the `{name}` Whisker module on Android.
353// Wired into the consumer app's settings.gradle.kts by whisker-build.
354// build.gradle.kts sits at the package root, alongside Package.swift
355// + Cargo.toml; the Kotlin source set points at the `android/` subdir.
356
357plugins {{
358 id("com.android.library")
359 id("org.jetbrains.kotlin.android")
360 id("com.google.devtools.ksp") version "2.0.21-1.0.27"
361}}
362
363android {{
364 namespace = "rs.whisker.modules.{ns}"
365 compileSdk = 34
366
367 defaultConfig {{
368 minSdk = 21
369 }}
370
371 compileOptions {{
372 sourceCompatibility = JavaVersion.VERSION_17
373 targetCompatibility = JavaVersion.VERSION_17
374 }}
375 kotlinOptions {{
376 jvmTarget = "17"
377 }}
378
379 sourceSets {{
380 getByName("main") {{
381 kotlin.srcDirs("android/src/main/kotlin")
382 }}
383 }}
384}}
385
386ksp {{
387 arg("whisker.moduleName", "{spm}")
388 arg("whisker.crateName", "{name}")
389}}
390
391dependencies {{
392 // Published Whisker runtime + KSP processor — the same Maven
393 // coordinates every first-party module uses. ksp(rs.whisker:ksp)
394 // stays separate because it's a build-time processor, not on the
395 // runtime classpath. The KSP processor discovers Module subclasses
396 // by inheritance (no marker annotation needed). The `{android_tag}`
397 // tag is the Android (Maven) release, versioned independently from
398 // the cargo crate.
399 implementation("rs.whisker:whisker-module-android:{android_tag}")
400 ksp("rs.whisker:ksp:{android_tag}")
401}}
402"#,
403 name = v.crate_name,
404 ns = v.ns,
405 spm = v.spm,
406 android_tag = WHISKER_ANDROID_MAVEN_TAG,
407 )
408}
409
410const WHISKER_ANDROID_MAVEN_TAG: &str = "0.1.0";
416
417fn lib_rs_view(v: &Vars) -> String {
418 format!(
419 r#"//! `{name}` — Whisker view-bearing module.
420//!
421//! Registers a Lynx element under `{name}:{tag}` and exposes the
422//! `{tag}` symbol for use inside `render!`. Platform-side classes
423//! live under `ios/` and `android/`.
424
425use whisker::Signal;
426
427/// View-bearing element. The Lynx tag the bridge registers against
428/// is `{name}:{tag}` — the crate name namespace is auto-prepended by
429/// `#[whisker::module_component]`. Imperative methods on a mounted
430/// instance go through an `ElementRef` (the `ref:` prop) — wrap one
431/// in a typed `{tag}Handle` struct for the public API.
432#[whisker::module_component("{tag}")]
433pub fn {ident}(style: Signal<String>) {{}}
434"#,
435 name = v.crate_name,
436 tag = v.tag,
437 ident = v.ident,
438 )
439}
440
441fn lib_rs_module(v: &Vars) -> String {
442 format!(
443 r#"//! `{name}` — Whisker function-only platform module.
444//!
445//! Exposes typed Rust -> Kotlin/Swift function calls without
446//! rendering UI. Platform-side classes live under `ios/` and
447//! `android/`.
448
449use whisker::platform_module::{{WhiskerModuleError, WhiskerValue}};
450
451/// Typed Rust API for the `Whisker{tag}` platform module.
452///
453/// Hand-written wrapper over the framework primitive: each method
454/// builds the raw `Vec<WhiskerValue>` arg list, dispatches via
455/// `whisker::module!("Whisker{tag}").invoke(method, args)`, and lifts
456/// the returned `WhiskerValue` into a typed `Result`. The `module!`
457/// name MUST match the `Name("...")` in the platform-side
458/// `definition()`; `module!` auto-prepends this crate's name so two
459/// crates can ship same-named modules without colliding.
460pub struct Whisker{tag};
461impl Whisker{tag} {{
462 pub fn placeholder() -> Result<(), WhiskerModuleError> {{
463 // Build args, dispatch, lift the WhiskerValue into a typed result.
464 match whisker::module!("Whisker{tag}").invoke("_placeholder", vec![]) {{
465 WhiskerValue::Error(msg) => Err(WhiskerModuleError(msg)),
466 _ => Ok(()),
467 }}
468 }}
469}}
470"#,
471 name = v.crate_name,
472 tag = v.tag,
473 )
474}
475
476fn swift_view_module(v: &Vars) -> String {
477 format!(
478 r#"// `{module_class}` — iOS side of the `{name}:{tag}` Whisker module.
479//
480// Declares the Lynx element `{name}:{tag}` via the ModuleDefinition
481// DSL. Subclassing `Module` is the registration signal — the SwiftPM
482// codegen plugin walks every `Module` subclass and emits the Lynx
483// behavior registration. The `{view_class}` Lynx UI subclass lives
484// in `{view_class}.swift`.
485
486import WhiskerModule // Module, ModuleDefinition, DSL
487
488public final class {module_class}: Module {{
489 public override func definition() -> ModuleDefinition {{
490 ModuleDefinition {{
491 Name("{tag}")
492 View({view_class}.self) {{
493 // Declare Prop / Function entries here, e.g.:
494 // Prop("title") {{ (view: {view_class}, value: String) in
495 // view.setTitle(value)
496 // }}
497 // Function("focus") {{ (view: {view_class}) in view.focus() }}
498 }}
499 }}
500 }}
501}}
502"#,
503 name = v.crate_name,
504 tag = v.tag,
505 module_class = v.module_class,
506 view_class = v.view_class,
507 )
508}
509
510fn swift_view(v: &Vars) -> String {
511 format!(
512 r#"// `{view_class}` — the Lynx UI subclass backing `{name}:{tag}`.
513// Instantiated by Lynx via the behavior `{module_class}.definition()`
514// registers. `@objc({view_class})` pins the Obj-C class name so the
515// codegen plugin's `NSClassFromString` lookup resolves it.
516
517import UIKit
518import WhiskerModule
519
520@objc({view_class})
521public final class {view_class}: WhiskerUI<UIView> {{
522 @objc public override func createView() -> UIView {{
523 let v = UIView()
524 v.backgroundColor = .systemPink
525 return v
526 }}
527}}
528"#,
529 name = v.crate_name,
530 tag = v.tag,
531 module_class = v.module_class,
532 view_class = v.view_class,
533 )
534}
535
536fn kotlin_view_module(v: &Vars) -> String {
537 format!(
538 r#"// `{module_class}` -- Android side of the `{name}:{tag}` Whisker module.
539//
540// Subclassing `Module` is the registration signal — the KSP processor
541// walks every concrete subclass and emits the Lynx behavior
542// registration. The `{view_class}` Lynx UI subclass lives in
543// `{view_class}.kt`.
544//
545// Note the explicit `import rs.whisker.runtime.Module` — without it
546// the unqualified `Module` resolves to `java.lang.Module` (a Kotlin
547// JVM default import).
548
549package rs.whisker.modules.{ns}
550
551import rs.whisker.runtime.Module
552import rs.whisker.runtime.ModuleDefinition
553
554class {module_class} : Module() {{
555 override fun definition() = ModuleDefinition {{
556 Name("{tag}")
557 View({view_class}::class.java) {{
558 // Declare Prop / Function entries here, e.g.:
559 // Prop("title") {{ view: {view_class}, value: String ->
560 // view.setTitle(value)
561 // }}
562 // Function("focus") {{ view: {view_class} -> view.focus() }}
563 }}
564 }}
565}}
566"#,
567 name = v.crate_name,
568 tag = v.tag,
569 ns = v.ns,
570 module_class = v.module_class,
571 view_class = v.view_class,
572 )
573}
574
575fn kotlin_view(v: &Vars) -> String {
576 format!(
577 r#"// `{view_class}` -- the Lynx UI subclass backing `{name}:{tag}`.
578// Instantiated by the Lynx behavior `{module_class}.definition()`
579// registers. The single-arg `(WhiskerContext)` constructor matches
580// the convention the KSP registration code expects.
581
582package rs.whisker.modules.{ns}
583
584import android.content.Context
585import android.graphics.Color
586import android.view.View
587import rs.whisker.runtime.WhiskerContext
588import rs.whisker.runtime.WhiskerUI
589
590open class {view_class}(context: WhiskerContext) : WhiskerUI<View>(context) {{
591 override fun createView(context: Context): View {{
592 val v = View(context)
593 v.setBackgroundColor(Color.argb(0xff, 0xff, 0x80, 0xa0))
594 return v
595 }}
596}}
597"#,
598 name = v.crate_name,
599 tag = v.tag,
600 ns = v.ns,
601 module_class = v.module_class,
602 view_class = v.view_class,
603 )
604}
605
606fn swift_function_module(v: &Vars) -> String {
607 format!(
608 r#"// `{module_class}` — iOS side of the `{name}` Whisker function-only module.
609//
610// A view-less DSL module: `definition()` has no `View(...)` block,
611// just module-level `Function`s. Subclassing `Module` is the
612// registration signal — the SwiftPM codegen plugin emits a dispatch
613// shim registered under the `Name("...")`, so
614// `Whisker{tag}::placeholder()` on the Rust side routes here.
615
616import WhiskerModule // Module, ModuleDefinition, DSL
617
618public final class {module_class}: Module {{
619 public override func definition() -> ModuleDefinition {{
620 ModuleDefinition {{
621 // The Name MUST match the Rust sys trait's
622 // `#[whisker::platform_module(name = "...")]`.
623 Name("Whisker{tag}")
624 Function("_placeholder") {{
625 // TODO: implement the function the Rust sys trait declares.
626 }}
627 }}
628 }}
629}}
630"#,
631 name = v.crate_name,
632 tag = v.tag,
633 module_class = v.module_class,
634 )
635}
636
637fn kotlin_function_module(v: &Vars) -> String {
638 format!(
639 r#"// `{module_class}` -- Android side of the `{name}` Whisker function-only module.
640//
641// A view-less DSL module: module-level `Function`s, no `View(...)`.
642// Subclassing `Module` is the registration signal. See the note in
643// the view-bearing template re: the explicit `Module` import.
644
645package rs.whisker.modules.{ns}
646
647import rs.whisker.runtime.Module
648import rs.whisker.runtime.ModuleDefinition
649
650class {module_class} : Module() {{
651 override fun definition() = ModuleDefinition {{
652 // The Name MUST match the Rust sys trait's
653 // `#[whisker::platform_module(name = "...")]`.
654 Name("Whisker{tag}")
655 Function("_placeholder") {{
656 // TODO: implement the function the Rust sys trait declares.
657 }}
658 }}
659}}
660"#,
661 name = v.crate_name,
662 tag = v.tag,
663 ns = v.ns,
664 module_class = v.module_class,
665 )
666}
667
668fn validate_crate_name(name: &str) -> Result<()> {
675 if name.is_empty() {
676 bail!("crate name must not be empty");
677 }
678 let first = name.chars().next().unwrap();
679 if !first.is_ascii_alphabetic() {
680 bail!(
681 "crate name must start with a letter, got {first:?}. Try \
682 `whisker-{name}` instead."
683 );
684 }
685 for ch in name.chars() {
686 if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
687 return Err(anyhow!(
688 "crate name {name:?} contains invalid character {ch:?}. \
689 Use only ASCII letters / digits / `-` / `_`."
690 ));
691 }
692 }
693 Ok(())
694}
695
696fn pascal_case_tag(crate_name: &str) -> String {
702 let stripped = crate_name.strip_prefix("whisker-").unwrap_or(crate_name);
703 let mut out = String::new();
704 let mut upper = true;
705 for ch in stripped.chars() {
706 if ch == '-' || ch == '_' {
707 upper = true;
708 } else if upper {
709 out.extend(ch.to_uppercase());
710 upper = false;
711 } else {
712 out.push(ch);
713 }
714 }
715 out
716}
717
718fn crate_to_spm_target(crate_name: &str) -> String {
721 let mut out = String::new();
722 let mut upper = true;
723 for ch in crate_name.chars() {
724 if ch == '-' || ch == '_' {
725 upper = true;
726 } else if upper {
727 out.extend(ch.to_uppercase());
728 upper = false;
729 } else {
730 out.push(ch);
731 }
732 }
733 out
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn pascal_strips_whisker_prefix() {
742 assert_eq!(pascal_case_tag("whisker-foo"), "Foo");
743 assert_eq!(pascal_case_tag("whisker-blur-view"), "BlurView");
744 }
745
746 #[test]
747 fn pascal_keeps_full_name_when_no_whisker_prefix() {
748 assert_eq!(pascal_case_tag("foo-bar"), "FooBar");
749 }
750
751 #[test]
752 fn spm_target_pascals_full_crate_name() {
753 assert_eq!(crate_to_spm_target("whisker-foo"), "WhiskerFoo");
754 assert_eq!(crate_to_spm_target("whisker-blur-view"), "WhiskerBlurView");
755 }
756
757 #[test]
758 fn validate_rejects_invalid() {
759 assert!(validate_crate_name("").is_err());
760 assert!(validate_crate_name("1foo").is_err());
761 assert!(validate_crate_name("whisker foo").is_err());
762 assert!(validate_crate_name("whisker-foo").is_ok());
763 assert!(validate_crate_name("whisker_foo").is_ok());
764 }
765}