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 docs/module-author-guide.md 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 cargo_toml(v: &Vars) -> String {
205 format!(
206 r#"[package]
207name = "{name}"
208version = "0.1.0"
209edition = "2021"
210license = "MIT OR Apache-2.0"
211description = "Whisker module — short tagline shown on crates.io."
212
213include = [
214 "Cargo.toml",
215 "Package.swift",
216 "build.gradle.kts",
217 "src/lib.rs",
218 "android/**/*.kt",
219 "ios/**/*.swift",
220 "README.md",
221]
222
223[lib]
224crate-type = ["rlib"]
225
226# Module-system opt-in marker — the bare table identifies this cargo
227# crate as a Whisker module, so `whisker-build` wires its `android/`
228# Gradle subproject + `ios/` SwiftPM package into the host build.
229[package.metadata.whisker]
230
231[dependencies]
232# The umbrella `whisker` crate. The proc macros' emit paths
233# (::whisker::ElementRef, ::whisker::platform_module::WhiskerValue, ...)
234# resolve under the `whisker` name — the same dep app crates use.
235whisker = "0.1"
236"#,
237 name = v.crate_name,
238 )
239}
240
241fn readme(v: &Vars) -> String {
242 format!(
243 r#"# {name}
244
245A Whisker module — registers the `{name}:{tag}` element under Lynx
246and exposes `{tag}` for use in Whisker app `render!` trees.
247
248## Usage
249
250```toml
251[dependencies]
252{name} = "0.1"
253```
254
255```rust
256use whisker::prelude::*;
257use {ident}::*;
258
259#[whisker::main]
260fn app() -> Element {{
261 render! {{
262 {tag}()
263 }}
264}}
265```
266
267See [the Whisker Module Author Guide](https://github.com/whiskerrs/whisker/blob/main/docs/module-author-guide.md)
268for the full reference.
269"#,
270 name = v.crate_name,
271 tag = v.tag,
272 ident = v.ident,
273 )
274}
275
276fn package_swift(v: &Vars) -> String {
277 format!(
278 r#"// swift-tools-version:5.9
279//
280// SwiftPM manifest for the `{name}` module's iOS half. The consumer
281// app's `whisker-build`-generated aggregator depends on the library
282// product below via `.product(name: "{spm}", package: "{name}")`.
283//
284// Package.swift lives at the package root (SwiftPM requires it
285// there); the Swift sources live under the `ios/` subdir alongside
286// `android/` + `src/`.
287
288import PackageDescription
289
290// whisker-build injects the absolute location of Whisker's iOS
291// runtime + macros packages via these env vars, so this module
292// resolves them wherever it lives — in a whisker project, or unpacked
293// from the cargo registry. A Whisker module is only ever built through
294// `whisker run` (which set these), never standalone.
295guard let whiskerRuntimePath = Context.environment["WHISKER_IOS_RUNTIME"],
296 let whiskerMacrosPath = Context.environment["WHISKER_IOS_MACROS"]
297else {{
298 fatalError("""
299 WHISKER_IOS_RUNTIME / WHISKER_IOS_MACROS not set. Build this Whisker \
300 module through `whisker run`, which inject these paths.
301 """)
302}}
303
304let package = Package(
305 name: "{name}",
306 platforms: [.iOS(.v13), .macOS(.v13)],
307 products: [
308 .library(name: "{spm}", targets: ["{spm}"]),
309 ],
310 dependencies: [
311 .package(name: "macros", path: whiskerMacrosPath),
312 .package(name: "WhiskerRuntime", path: whiskerRuntimePath),
313 ],
314 targets: [
315 .target(
316 name: "{spm}",
317 dependencies: [
318 .product(name: "WhiskerModule", package: "WhiskerRuntime"),
319 ],
320 path: "ios/Sources/{spm}",
321 plugins: [
322 .plugin(name: "WhiskerModuleCodegenPlugin", package: "macros"),
323 ]
324 ),
325 ]
326)
327"#,
328 name = v.crate_name,
329 spm = v.spm,
330 )
331}
332
333fn build_gradle(v: &Vars) -> String {
334 format!(
335 r#"// Gradle subproject for the `{name}` Whisker module on Android.
336// Wired into the consumer app's settings.gradle.kts by whisker-build.
337// build.gradle.kts sits at the package root, alongside Package.swift
338// + Cargo.toml; the Kotlin source set points at the `android/` subdir.
339
340plugins {{
341 id("com.android.library")
342 id("org.jetbrains.kotlin.android")
343 id("com.google.devtools.ksp") version "2.0.21-1.0.27"
344}}
345
346android {{
347 namespace = "rs.whisker.modules.{ns}"
348 compileSdk = 34
349
350 defaultConfig {{
351 minSdk = 21
352 }}
353
354 compileOptions {{
355 sourceCompatibility = JavaVersion.VERSION_17
356 targetCompatibility = JavaVersion.VERSION_17
357 }}
358 kotlinOptions {{
359 jvmTarget = "17"
360 }}
361
362 sourceSets {{
363 getByName("main") {{
364 kotlin.srcDirs("android/src/main/kotlin")
365 }}
366 }}
367}}
368
369ksp {{
370 arg("whisker.moduleName", "{spm}")
371 arg("whisker.crateName", "{name}")
372}}
373
374dependencies {{
375 // Single Whisker runtime dep. ksp(rs.whisker:ksp) stays
376 // separate because it's a build-time processor, not on the
377 // runtime classpath. The KSP processor discovers Module
378 // subclasses by inheritance (no marker annotation needed).
379 implementation(project(":module"))
380 ksp("rs.whisker:ksp")
381}}
382"#,
383 name = v.crate_name,
384 ns = v.ns,
385 spm = v.spm,
386 )
387}
388
389fn lib_rs_view(v: &Vars) -> String {
390 format!(
391 r#"//! `{name}` — Whisker view-bearing module.
392//!
393//! Registers a Lynx element under `{name}:{tag}` and exposes the
394//! `{tag}` symbol for use inside `render!`. Platform-side classes
395//! live under `ios/` and `android/`.
396
397use whisker::Signal;
398
399/// View-bearing element. The Lynx tag the bridge registers against
400/// is `{name}:{tag}` — the crate name namespace is auto-prepended by
401/// `#[whisker::module_component]`. Imperative methods on a mounted
402/// instance go through an `ElementRef` (the `ref:` prop) — wrap one
403/// in a typed `{tag}Handle` struct for the public API.
404#[whisker::module_component("{tag}")]
405pub fn {ident}(style: Signal<String>) {{}}
406"#,
407 name = v.crate_name,
408 tag = v.tag,
409 ident = v.ident,
410 )
411}
412
413fn lib_rs_module(v: &Vars) -> String {
414 format!(
415 r#"//! `{name}` — Whisker function-only platform module.
416//!
417//! Exposes typed Rust -> Kotlin/Swift function calls without
418//! rendering UI. Platform-side classes live under `ios/` and
419//! `android/`.
420
421use whisker::platform_module::{{WhiskerModuleError, WhiskerValue}};
422
423/// Typed Rust API for the `Whisker{tag}` platform module.
424///
425/// Hand-written wrapper over the framework primitive: each method
426/// builds the raw `Vec<WhiskerValue>` arg list, dispatches via
427/// `whisker::module!("Whisker{tag}").invoke(method, args)`, and lifts
428/// the returned `WhiskerValue` into a typed `Result`. The `module!`
429/// name MUST match the `Name("...")` in the platform-side
430/// `definition()`; `module!` auto-prepends this crate's name so two
431/// crates can ship same-named modules without colliding.
432pub struct Whisker{tag};
433impl Whisker{tag} {{
434 pub fn placeholder() -> Result<(), WhiskerModuleError> {{
435 // Build args, dispatch, lift the WhiskerValue into a typed result.
436 match whisker::module!("Whisker{tag}").invoke("_placeholder", vec![]) {{
437 WhiskerValue::Error(msg) => Err(WhiskerModuleError(msg)),
438 _ => Ok(()),
439 }}
440 }}
441}}
442"#,
443 name = v.crate_name,
444 tag = v.tag,
445 )
446}
447
448fn swift_view_module(v: &Vars) -> String {
449 format!(
450 r#"// `{module_class}` — iOS side of the `{name}:{tag}` Whisker module.
451//
452// Declares the Lynx element `{name}:{tag}` via the ModuleDefinition
453// DSL. Subclassing `Module` is the registration signal — the SwiftPM
454// codegen plugin walks every `Module` subclass and emits the Lynx
455// behavior registration. The `{view_class}` Lynx UI subclass lives
456// in `{view_class}.swift`.
457
458import WhiskerModule // Module, ModuleDefinition, DSL
459
460public final class {module_class}: Module {{
461 public override func definition() -> ModuleDefinition {{
462 ModuleDefinition {{
463 Name("{tag}")
464 View({view_class}.self) {{
465 // Declare Prop / Function entries here, e.g.:
466 // Prop("title") {{ (view: {view_class}, value: String) in
467 // view.setTitle(value)
468 // }}
469 // Function("focus") {{ (view: {view_class}) in view.focus() }}
470 }}
471 }}
472 }}
473}}
474"#,
475 name = v.crate_name,
476 tag = v.tag,
477 module_class = v.module_class,
478 view_class = v.view_class,
479 )
480}
481
482fn swift_view(v: &Vars) -> String {
483 format!(
484 r#"// `{view_class}` — the Lynx UI subclass backing `{name}:{tag}`.
485// Instantiated by Lynx via the behavior `{module_class}.definition()`
486// registers. `@objc({view_class})` pins the Obj-C class name so the
487// codegen plugin's `NSClassFromString` lookup resolves it.
488
489import UIKit
490import WhiskerModule
491
492@objc({view_class})
493public final class {view_class}: WhiskerUI<UIView> {{
494 @objc public override func createView() -> UIView {{
495 let v = UIView()
496 v.backgroundColor = .systemPink
497 return v
498 }}
499}}
500"#,
501 name = v.crate_name,
502 tag = v.tag,
503 module_class = v.module_class,
504 view_class = v.view_class,
505 )
506}
507
508fn kotlin_view_module(v: &Vars) -> String {
509 format!(
510 r#"// `{module_class}` -- Android side of the `{name}:{tag}` Whisker module.
511//
512// Subclassing `Module` is the registration signal — the KSP processor
513// walks every concrete subclass and emits the Lynx behavior
514// registration. The `{view_class}` Lynx UI subclass lives in
515// `{view_class}.kt`.
516//
517// Note the explicit `import rs.whisker.runtime.Module` — without it
518// the unqualified `Module` resolves to `java.lang.Module` (a Kotlin
519// JVM default import).
520
521package rs.whisker.modules.{ns}
522
523import rs.whisker.runtime.Module
524import rs.whisker.runtime.ModuleDefinition
525
526class {module_class} : Module() {{
527 override fun definition() = ModuleDefinition {{
528 Name("{tag}")
529 View({view_class}::class.java) {{
530 // Declare Prop / Function entries here, e.g.:
531 // Prop("title") {{ view: {view_class}, value: String ->
532 // view.setTitle(value)
533 // }}
534 // Function("focus") {{ view: {view_class} -> view.focus() }}
535 }}
536 }}
537}}
538"#,
539 name = v.crate_name,
540 tag = v.tag,
541 ns = v.ns,
542 module_class = v.module_class,
543 view_class = v.view_class,
544 )
545}
546
547fn kotlin_view(v: &Vars) -> String {
548 format!(
549 r#"// `{view_class}` -- the Lynx UI subclass backing `{name}:{tag}`.
550// Instantiated by the Lynx behavior `{module_class}.definition()`
551// registers. The single-arg `(WhiskerContext)` constructor matches
552// the convention the KSP registration code expects.
553
554package rs.whisker.modules.{ns}
555
556import android.content.Context
557import android.graphics.Color
558import android.view.View
559import rs.whisker.runtime.WhiskerContext
560import rs.whisker.runtime.WhiskerUI
561
562open class {view_class}(context: WhiskerContext) : WhiskerUI<View>(context) {{
563 override fun createView(context: Context): View {{
564 val v = View(context)
565 v.setBackgroundColor(Color.argb(0xff, 0xff, 0x80, 0xa0))
566 return v
567 }}
568}}
569"#,
570 name = v.crate_name,
571 tag = v.tag,
572 ns = v.ns,
573 module_class = v.module_class,
574 view_class = v.view_class,
575 )
576}
577
578fn swift_function_module(v: &Vars) -> String {
579 format!(
580 r#"// `{module_class}` — iOS side of the `{name}` Whisker function-only module.
581//
582// A view-less DSL module: `definition()` has no `View(...)` block,
583// just module-level `Function`s. Subclassing `Module` is the
584// registration signal — the SwiftPM codegen plugin emits a dispatch
585// shim registered under the `Name("...")`, so
586// `Whisker{tag}::placeholder()` on the Rust side routes here.
587
588import WhiskerModule // Module, ModuleDefinition, DSL
589
590public final class {module_class}: Module {{
591 public override func definition() -> ModuleDefinition {{
592 ModuleDefinition {{
593 // The Name MUST match the Rust sys trait's
594 // `#[whisker::platform_module(name = "...")]`.
595 Name("Whisker{tag}")
596 Function("_placeholder") {{
597 // TODO: implement the function the Rust sys trait declares.
598 }}
599 }}
600 }}
601}}
602"#,
603 name = v.crate_name,
604 tag = v.tag,
605 module_class = v.module_class,
606 )
607}
608
609fn kotlin_function_module(v: &Vars) -> String {
610 format!(
611 r#"// `{module_class}` -- Android side of the `{name}` Whisker function-only module.
612//
613// A view-less DSL module: module-level `Function`s, no `View(...)`.
614// Subclassing `Module` is the registration signal. See the note in
615// the view-bearing template re: the explicit `Module` import.
616
617package rs.whisker.modules.{ns}
618
619import rs.whisker.runtime.Module
620import rs.whisker.runtime.ModuleDefinition
621
622class {module_class} : Module() {{
623 override fun definition() = ModuleDefinition {{
624 // The Name MUST match the Rust sys trait's
625 // `#[whisker::platform_module(name = "...")]`.
626 Name("Whisker{tag}")
627 Function("_placeholder") {{
628 // TODO: implement the function the Rust sys trait declares.
629 }}
630 }}
631}}
632"#,
633 name = v.crate_name,
634 tag = v.tag,
635 ns = v.ns,
636 module_class = v.module_class,
637 )
638}
639
640fn validate_crate_name(name: &str) -> Result<()> {
647 if name.is_empty() {
648 bail!("crate name must not be empty");
649 }
650 let first = name.chars().next().unwrap();
651 if !first.is_ascii_alphabetic() {
652 bail!(
653 "crate name must start with a letter, got {first:?}. Try \
654 `whisker-{name}` instead."
655 );
656 }
657 for ch in name.chars() {
658 if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
659 return Err(anyhow!(
660 "crate name {name:?} contains invalid character {ch:?}. \
661 Use only ASCII letters / digits / `-` / `_`."
662 ));
663 }
664 }
665 Ok(())
666}
667
668fn pascal_case_tag(crate_name: &str) -> String {
674 let stripped = crate_name.strip_prefix("whisker-").unwrap_or(crate_name);
675 let mut out = String::new();
676 let mut upper = true;
677 for ch in stripped.chars() {
678 if ch == '-' || ch == '_' {
679 upper = true;
680 } else if upper {
681 out.extend(ch.to_uppercase());
682 upper = false;
683 } else {
684 out.push(ch);
685 }
686 }
687 out
688}
689
690fn crate_to_spm_target(crate_name: &str) -> String {
693 let mut out = String::new();
694 let mut upper = true;
695 for ch in crate_name.chars() {
696 if ch == '-' || ch == '_' {
697 upper = true;
698 } else if upper {
699 out.extend(ch.to_uppercase());
700 upper = false;
701 } else {
702 out.push(ch);
703 }
704 }
705 out
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn pascal_strips_whisker_prefix() {
714 assert_eq!(pascal_case_tag("whisker-foo"), "Foo");
715 assert_eq!(pascal_case_tag("whisker-blur-view"), "BlurView");
716 }
717
718 #[test]
719 fn pascal_keeps_full_name_when_no_whisker_prefix() {
720 assert_eq!(pascal_case_tag("foo-bar"), "FooBar");
721 }
722
723 #[test]
724 fn spm_target_pascals_full_crate_name() {
725 assert_eq!(crate_to_spm_target("whisker-foo"), "WhiskerFoo");
726 assert_eq!(crate_to_spm_target("whisker-blur-view"), "WhiskerBlurView");
727 }
728
729 #[test]
730 fn validate_rejects_invalid() {
731 assert!(validate_crate_name("").is_err());
732 assert!(validate_crate_name("1foo").is_err());
733 assert!(validate_crate_name("whisker foo").is_err());
734 assert!(validate_crate_name("whisker-foo").is_ok());
735 assert!(validate_crate_name("whisker_foo").is_ok());
736 }
737}