cargo_component/commands/
new.rs

1use std::{
2    borrow::Cow,
3    fs,
4    path::{Path, PathBuf},
5    process::Command,
6    sync::Arc,
7};
8
9use anyhow::{bail, Context, Result};
10use cargo_component_core::{
11    command::CommonOptions,
12    registry::{Dependency, DependencyResolution, DependencyResolver, RegistryResolution},
13};
14use clap::Args;
15use heck::ToKebabCase;
16use semver::VersionReq;
17use toml_edit::{table, value, DocumentMut, Item, Table, Value};
18use wasm_pkg_client::caching::{CachingClient, FileCache};
19
20use crate::{
21    config::Config, generate_bindings, generator::SourceGenerator, load_component_metadata,
22    load_metadata, metadata, metadata::DEFAULT_WIT_DIR, CargoArguments,
23};
24
25const WIT_BINDGEN_RT_CRATE: &str = "wit-bindgen-rt";
26
27fn escape_wit(s: &str) -> Cow<str> {
28    match s {
29        "use" | "type" | "func" | "u8" | "u16" | "u32" | "u64" | "s8" | "s16" | "s32" | "s64"
30        | "float32" | "float64" | "char" | "record" | "flags" | "variant" | "enum" | "union"
31        | "bool" | "string" | "option" | "result" | "future" | "stream" | "list" | "_" | "as"
32        | "from" | "static" | "interface" | "tuple" | "import" | "export" | "world" | "package" => {
33            Cow::Owned(format!("%{s}"))
34        }
35        _ => s.into(),
36    }
37}
38
39/// Create a new WebAssembly component package at <path>
40#[derive(Args)]
41#[clap(disable_version_flag = true)]
42pub struct NewCommand {
43    /// The common command options.
44    #[clap(flatten)]
45    pub common: CommonOptions,
46
47    /// Initialize a new repository for the given version
48    /// control system (git, hg, pijul, or fossil) or do not
49    /// initialize any version control at all (none), overriding
50    /// a global configuration.
51    #[clap(long = "vcs", value_name = "VCS", value_parser = ["git", "hg", "pijul", "fossil", "none"])]
52    pub vcs: Option<String>,
53
54    /// Create a CLI command component [default]
55    #[clap(long = "bin", alias = "command", conflicts_with = "lib")]
56    pub bin: bool,
57
58    /// Create a library (reactor) component
59    #[clap(long = "lib", alias = "reactor")]
60    pub lib: bool,
61
62    /// Use the built-in `wasi:http/proxy` module adapter
63    #[clap(long = "proxy", requires = "lib")]
64    pub proxy: bool,
65
66    /// Edition to set for the generated crate
67    #[clap(long = "edition", value_name = "YEAR", value_parser = ["2015", "2018", "2021"])]
68    pub edition: Option<String>,
69
70    /// The component package namespace to use.
71    #[clap(
72        long = "namespace",
73        value_name = "NAMESPACE",
74        default_value = "component"
75    )]
76    pub namespace: String,
77
78    /// Set the resulting package name, defaults to the directory name
79    #[clap(long = "name", value_name = "NAME")]
80    pub name: Option<String>,
81
82    /// Code editor to use for rust-analyzer integration, defaults to `vscode`
83    #[clap(long = "editor", value_name = "EDITOR", value_parser = ["emacs", "vscode", "none"])]
84    pub editor: Option<String>,
85
86    /// Use the specified target world from a WIT package.
87    #[clap(long = "target", short = 't', value_name = "TARGET", requires = "lib")]
88    pub target: Option<String>,
89
90    /// Use the specified default registry when generating the package.
91    #[clap(long = "registry", value_name = "REGISTRY")]
92    pub registry: Option<String>,
93
94    /// Disable the use of `rustfmt` when generating source code.
95    #[clap(long = "no-rustfmt")]
96    pub no_rustfmt: bool,
97
98    /// The path for the generated package.
99    #[clap(value_name = "path")]
100    pub path: PathBuf,
101}
102
103struct PackageName<'a> {
104    namespace: String,
105    name: String,
106    display: Cow<'a, str>,
107}
108
109impl<'a> PackageName<'a> {
110    fn new(namespace: &str, name: Option<&'a str>, path: &'a Path) -> Result<Self> {
111        let (name, display) = match name {
112            Some(name) => (name.into(), name.into()),
113            None => (
114                path.file_name().expect("invalid path").to_string_lossy(),
115                // `cargo new` prints the given path to the new package, so
116                // use the path for the display value.
117                path.as_os_str().to_string_lossy(),
118            ),
119        };
120
121        let namespace_kebab = namespace.to_kebab_case();
122        if namespace_kebab.is_empty() {
123            bail!("invalid component namespace `{namespace}`");
124        }
125
126        wit_parser::validate_id(&namespace_kebab).with_context(|| {
127            format!("component namespace `{namespace}` is not a legal WIT identifier")
128        })?;
129
130        let name_kebab = name.to_kebab_case();
131        if name_kebab.is_empty() {
132            bail!("invalid component name `{name}`");
133        }
134
135        wit_parser::validate_id(&name_kebab)
136            .with_context(|| format!("component name `{name}` is not a legal WIT identifier"))?;
137
138        Ok(Self {
139            namespace: namespace_kebab,
140            name: name_kebab,
141            display,
142        })
143    }
144}
145
146impl NewCommand {
147    /// Executes the command.
148    pub async fn exec(self) -> Result<()> {
149        log::debug!("executing new command");
150
151        let config = Config::new(self.common.new_terminal(), self.common.config.clone()).await?;
152
153        let name = PackageName::new(&self.namespace, self.name.as_deref(), &self.path)?;
154
155        let out_dir = std::env::current_dir()
156            .with_context(|| "couldn't get the current directory of the process")?
157            .join(&self.path);
158
159        let target: Option<metadata::Target> = match self.target.as_deref() {
160            Some(s) if s.contains('@') => Some(s.parse()?),
161            Some(s) => Some(format!("{s}@{version}", version = VersionReq::STAR).parse()?),
162            None => None,
163        };
164        let client = config.client(self.common.cache_dir.clone(), false).await?;
165        let target = self.resolve_target(Arc::clone(&client), target).await?;
166        let source = self.generate_source(&target).await?;
167
168        let mut command = self.new_command();
169        match command.status() {
170            Ok(status) => {
171                if !status.success() {
172                    std::process::exit(status.code().unwrap_or(1));
173                }
174            }
175            Err(e) => {
176                bail!("failed to execute `cargo new` command: {e}")
177            }
178        }
179
180        let target = target.map(|(res, world)| {
181            match res {
182                DependencyResolution::Registry(reg) => (reg, world),
183                // This is unreachable because when we got the initial target, we made sure it was a
184                // registry target.
185                _ => unreachable!(),
186            }
187        });
188        self.update_manifest(&config, &name, &out_dir, &target)?;
189        self.create_source_file(&config, &out_dir, source.as_ref(), &target)?;
190        self.create_targets_file(&name, &out_dir)?;
191        self.create_editor_settings_file(&out_dir)?;
192
193        // Now that we've created the project, generate the bindings so that
194        // users can start looking at code with an IDE and not see red squiggles.
195        let cargo_args = CargoArguments::parse()?;
196        let manifest_path = out_dir.join("Cargo.toml");
197        let metadata = load_metadata(Some(&manifest_path))?;
198        let packages =
199            load_component_metadata(&metadata, cargo_args.packages.iter(), cargo_args.workspace)?;
200        let _import_name_map =
201            generate_bindings(client, &config, &metadata, &packages, &cargo_args).await?;
202
203        Ok(())
204    }
205
206    fn new_command(&self) -> Command {
207        let mut command = std::process::Command::new("cargo");
208        command.arg("new");
209
210        if let Some(name) = &self.name {
211            command.arg("--name").arg(name);
212        }
213
214        if let Some(edition) = &self.edition {
215            command.arg("--edition").arg(edition);
216        }
217
218        if let Some(vcs) = &self.vcs {
219            command.arg("--vcs").arg(vcs);
220        }
221
222        if self.common.quiet {
223            command.arg("-q");
224        }
225
226        command.args(std::iter::repeat("-v").take(self.common.verbose as usize));
227
228        if let Some(color) = self.common.color {
229            command.arg("--color").arg(color.to_string());
230        }
231
232        if !self.is_command() {
233            command.arg("--lib");
234        }
235
236        command.arg(&self.path);
237        command
238    }
239
240    fn update_manifest(
241        &self,
242        config: &Config,
243        name: &PackageName,
244        out_dir: &Path,
245        target: &Option<(RegistryResolution, Option<String>)>,
246    ) -> Result<()> {
247        let manifest_path = out_dir.join("Cargo.toml");
248        let manifest = fs::read_to_string(&manifest_path).with_context(|| {
249            format!(
250                "failed to read manifest file `{path}`",
251                path = manifest_path.display()
252            )
253        })?;
254
255        let mut doc: DocumentMut = manifest.parse().with_context(|| {
256            format!(
257                "failed to parse manifest file `{path}`",
258                path = manifest_path.display()
259            )
260        })?;
261
262        if !self.is_command() {
263            doc["lib"] = table();
264            doc["lib"]["crate-type"] = value(Value::from_iter(["cdylib"]));
265        }
266
267        // add release profile
268        let mut release_profile = table();
269        release_profile["codegen-units"] = value(1);
270        release_profile["opt-level"] = value("s");
271        release_profile["debug"] = value(false);
272        release_profile["strip"] = value(true);
273        release_profile["lto"] = value(true);
274        let mut profile = table();
275        profile.as_table_mut().unwrap().set_implicit(true);
276        profile["release"] = release_profile;
277        doc["profile"] = profile;
278
279        let mut component = Table::new();
280        component.set_implicit(true);
281
282        component["package"] = value(format!(
283            "{ns}:{name}",
284            ns = name.namespace,
285            name = name.name
286        ));
287
288        if !self.is_command() {
289            if let Some((resolution, world)) = target.as_ref() {
290                // if specifying exact version, set that exact version in the Cargo.toml
291                let version = if !resolution.requirement.comparators.is_empty()
292                    && resolution.requirement.comparators[0].op == semver::Op::Exact
293                {
294                    format!("={}", resolution.version)
295                } else {
296                    format!("{}", resolution.version)
297                };
298                component["target"] = match world {
299                    Some(world) => {
300                        value(format!("{name}/{world}@{version}", name = resolution.name,))
301                    }
302                    None => value(format!("{name}@{version}", name = resolution.name,)),
303                };
304            }
305        }
306
307        component["dependencies"] = Item::Table(Table::new());
308
309        if self.proxy {
310            component["proxy"] = value(true);
311        }
312
313        let mut metadata = Table::new();
314        metadata.set_implicit(true);
315        metadata.set_position(doc.len());
316        metadata["component"] = Item::Table(component);
317        doc["package"]["metadata"] = Item::Table(metadata);
318
319        fs::write(&manifest_path, doc.to_string()).with_context(|| {
320            format!(
321                "failed to write manifest file `{path}`",
322                path = manifest_path.display()
323            )
324        })?;
325
326        // Run cargo add for wit-bindgen and bitflags
327        let mut cargo_add_command = std::process::Command::new("cargo");
328        cargo_add_command.arg("add");
329        cargo_add_command.arg("--quiet");
330        cargo_add_command.arg(WIT_BINDGEN_RT_CRATE);
331        cargo_add_command.arg("--features");
332        cargo_add_command.arg("bitflags");
333        cargo_add_command.current_dir(out_dir);
334        let status = cargo_add_command
335            .status()
336            .context("failed to execute `cargo add` command")?;
337        if !status.success() {
338            bail!("`cargo add {WIT_BINDGEN_RT_CRATE} --features bitflags` command exited with non-zero status");
339        }
340
341        config.terminal().status(
342            "Updated",
343            format!("manifest of package `{name}`", name = name.display),
344        )?;
345
346        Ok(())
347    }
348
349    fn is_command(&self) -> bool {
350        self.bin || !self.lib
351    }
352
353    async fn generate_source(
354        &self,
355        target: &Option<(DependencyResolution, Option<String>)>,
356    ) -> Result<Cow<str>> {
357        match target {
358            Some((resolution, world)) => {
359                let generator =
360                    SourceGenerator::new(resolution, resolution.name(), !self.no_rustfmt);
361                generator.generate(world.as_deref()).await.map(Into::into)
362            }
363            None => {
364                if self.is_command() {
365                    Ok(r#"fn main() {
366    println!("Hello, world!");
367}
368"#
369                    .into())
370                } else {
371                    Ok(r#"#[allow(warnings)]
372mod bindings;
373
374use bindings::Guest;
375
376struct Component;
377
378impl Guest for Component {
379    /// Say hello!
380    fn hello_world() -> String {
381        "Hello, World!".to_string()
382    }
383}
384
385bindings::export!(Component with_types_in bindings);
386"#
387                    .into())
388                }
389            }
390        }
391    }
392
393    fn create_source_file(
394        &self,
395        config: &Config,
396        out_dir: &Path,
397        source: &str,
398        target: &Option<(RegistryResolution, Option<String>)>,
399    ) -> Result<()> {
400        let path = if self.is_command() {
401            "src/main.rs"
402        } else {
403            "src/lib.rs"
404        };
405
406        let source_path = out_dir.join(path);
407        fs::write(&source_path, source).with_context(|| {
408            format!(
409                "failed to write source file `{path}`",
410                path = source_path.display()
411            )
412        })?;
413
414        match target {
415            Some((resolution, _)) => {
416                config.terminal().status(
417                    "Generated",
418                    format!(
419                        "source file `{path}` for target `{name}` v{version}",
420                        name = resolution.name,
421                        version = resolution.version
422                    ),
423                )?;
424            }
425            None => {
426                config
427                    .terminal()
428                    .status("Generated", format!("source file `{path}`"))?;
429            }
430        }
431
432        Ok(())
433    }
434
435    fn create_targets_file(&self, name: &PackageName, out_dir: &Path) -> Result<()> {
436        if self.is_command() || self.target.is_some() {
437            return Ok(());
438        }
439
440        let wit_path = out_dir.join(DEFAULT_WIT_DIR);
441        fs::create_dir(&wit_path).with_context(|| {
442            format!(
443                "failed to create targets directory `{wit_path}`",
444                wit_path = wit_path.display()
445            )
446        })?;
447
448        let path = wit_path.join("world.wit");
449
450        fs::write(
451            &path,
452            format!(
453                r#"package {ns}:{pkg};
454
455/// An example world for the component to target.
456world example {{
457    export hello-world: func() -> string;
458}}
459"#,
460                ns = escape_wit(&name.namespace),
461                pkg = escape_wit(&name.name),
462            ),
463        )
464        .with_context(|| {
465            format!(
466                "failed to write targets file `{path}`",
467                path = path.display()
468            )
469        })
470    }
471
472    fn create_editor_settings_file(&self, out_dir: &Path) -> Result<()> {
473        match self.editor.as_deref() {
474            Some("vscode") | None => {
475                let settings_dir = out_dir.join(".vscode");
476                let settings_path = settings_dir.join("settings.json");
477
478                fs::create_dir_all(settings_dir)?;
479
480                fs::write(
481                    &settings_path,
482                    r#"{
483    "rust-analyzer.check.overrideCommand": [
484        "cargo",
485        "component",
486        "check",
487        "--workspace",
488        "--all-targets",
489        "--message-format=json"
490    ],
491}
492"#,
493                )
494                .with_context(|| {
495                    format!(
496                        "failed to write editor settings file `{path}`",
497                        path = settings_path.display()
498                    )
499                })
500            }
501            Some("emacs") => {
502                let settings_path = out_dir.join(".dir-locals.el");
503
504                fs::create_dir_all(out_dir)?;
505
506                fs::write(
507                    &settings_path,
508                    r#";;; Directory Local Variables
509;;; For more information see (info "(emacs) Directory Variables")
510
511((lsp-mode . ((lsp-rust-analyzer-cargo-watch-args . ["check"
512                                                     (\, "--message-format=json")])
513              (lsp-rust-analyzer-cargo-watch-command . "component")
514              (lsp-rust-analyzer-cargo-override-command . ["cargo"
515                                                           (\, "component")
516                                                           (\, "check")
517                                                           (\, "--workspace")
518                                                           (\, "--all-targets")
519                                                           (\, "--message-format=json")]))))
520"#,
521                )
522                .with_context(|| {
523                    format!(
524                        "failed to write editor settings file `{path}`",
525                        path = settings_path.display()
526                    )
527                })
528            }
529            Some("none") => Ok(()),
530            _ => unreachable!(),
531        }
532    }
533
534    /// This will always return a registry resolution if it is `Some`, but we return the
535    /// `DependencyResolution` instead so we can actually resolve the dependency.
536    async fn resolve_target(
537        &self,
538        client: Arc<CachingClient<FileCache>>,
539        target: Option<metadata::Target>,
540    ) -> Result<Option<(DependencyResolution, Option<String>)>> {
541        match target {
542            Some(metadata::Target::Package {
543                name,
544                package,
545                world,
546            }) => {
547                let mut resolver = DependencyResolver::new_with_client(client, None)?;
548                let dependency = Dependency::Package(package);
549
550                resolver.add_dependency(&name, &dependency).await?;
551
552                let dependencies = resolver.resolve().await?;
553                assert_eq!(dependencies.len(), 1);
554
555                Ok(Some((
556                    dependencies
557                        .into_values()
558                        .next()
559                        .expect("expected a target resolution"),
560                    world,
561                )))
562            }
563            _ => Ok(None),
564        }
565    }
566}