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        let mut component = Table::new();
268        component.set_implicit(true);
269
270        component["package"] = value(format!(
271            "{ns}:{name}",
272            ns = name.namespace,
273            name = name.name
274        ));
275
276        if !self.is_command() {
277            if let Some((resolution, world)) = target.as_ref() {
278                // if specifying exact version, set that exact version in the Cargo.toml
279                let version = if !resolution.requirement.comparators.is_empty()
280                    && resolution.requirement.comparators[0].op == semver::Op::Exact
281                {
282                    format!("={}", resolution.version)
283                } else {
284                    format!("{}", resolution.version)
285                };
286                component["target"] = match world {
287                    Some(world) => {
288                        value(format!("{name}/{world}@{version}", name = resolution.name,))
289                    }
290                    None => value(format!("{name}@{version}", name = resolution.name,)),
291                };
292            }
293        }
294
295        component["dependencies"] = Item::Table(Table::new());
296
297        if self.proxy {
298            component["proxy"] = value(true);
299        }
300
301        let mut metadata = Table::new();
302        metadata.set_implicit(true);
303        metadata.set_position(doc.len());
304        metadata["component"] = Item::Table(component);
305        doc["package"]["metadata"] = Item::Table(metadata);
306
307        fs::write(&manifest_path, doc.to_string()).with_context(|| {
308            format!(
309                "failed to write manifest file `{path}`",
310                path = manifest_path.display()
311            )
312        })?;
313
314        // Run cargo add for wit-bindgen and bitflags
315        let mut cargo_add_command = std::process::Command::new("cargo");
316        cargo_add_command.arg("add");
317        cargo_add_command.arg("--quiet");
318        cargo_add_command.arg(WIT_BINDGEN_RT_CRATE);
319        cargo_add_command.arg("--features");
320        cargo_add_command.arg("bitflags");
321        cargo_add_command.current_dir(out_dir);
322        let status = cargo_add_command
323            .status()
324            .context("failed to execute `cargo add` command")?;
325        if !status.success() {
326            bail!("`cargo add {WIT_BINDGEN_RT_CRATE} --features bitflags` command exited with non-zero status");
327        }
328
329        config.terminal().status(
330            "Updated",
331            format!("manifest of package `{name}`", name = name.display),
332        )?;
333
334        Ok(())
335    }
336
337    fn is_command(&self) -> bool {
338        self.bin || !self.lib
339    }
340
341    async fn generate_source(
342        &self,
343        target: &Option<(DependencyResolution, Option<String>)>,
344    ) -> Result<Cow<str>> {
345        match target {
346            Some((resolution, world)) => {
347                let generator =
348                    SourceGenerator::new(resolution, resolution.name(), !self.no_rustfmt);
349                generator.generate(world.as_deref()).await.map(Into::into)
350            }
351            None => {
352                if self.is_command() {
353                    Ok(r#"fn main() {
354    println!("Hello, world!");
355}
356"#
357                    .into())
358                } else {
359                    Ok(r#"#[allow(warnings)]
360mod bindings;
361
362use bindings::Guest;
363
364struct Component;
365
366impl Guest for Component {
367    /// Say hello!
368    fn hello_world() -> String {
369        "Hello, World!".to_string()
370    }
371}
372
373bindings::export!(Component with_types_in bindings);
374"#
375                    .into())
376                }
377            }
378        }
379    }
380
381    fn create_source_file(
382        &self,
383        config: &Config,
384        out_dir: &Path,
385        source: &str,
386        target: &Option<(RegistryResolution, Option<String>)>,
387    ) -> Result<()> {
388        let path = if self.is_command() {
389            "src/main.rs"
390        } else {
391            "src/lib.rs"
392        };
393
394        let source_path = out_dir.join(path);
395        fs::write(&source_path, source).with_context(|| {
396            format!(
397                "failed to write source file `{path}`",
398                path = source_path.display()
399            )
400        })?;
401
402        match target {
403            Some((resolution, _)) => {
404                config.terminal().status(
405                    "Generated",
406                    format!(
407                        "source file `{path}` for target `{name}` v{version}",
408                        name = resolution.name,
409                        version = resolution.version
410                    ),
411                )?;
412            }
413            None => {
414                config
415                    .terminal()
416                    .status("Generated", format!("source file `{path}`"))?;
417            }
418        }
419
420        Ok(())
421    }
422
423    fn create_targets_file(&self, name: &PackageName, out_dir: &Path) -> Result<()> {
424        if self.is_command() || self.target.is_some() {
425            return Ok(());
426        }
427
428        let wit_path = out_dir.join(DEFAULT_WIT_DIR);
429        fs::create_dir(&wit_path).with_context(|| {
430            format!(
431                "failed to create targets directory `{wit_path}`",
432                wit_path = wit_path.display()
433            )
434        })?;
435
436        let path = wit_path.join("world.wit");
437
438        fs::write(
439            &path,
440            format!(
441                r#"package {ns}:{pkg};
442
443/// An example world for the component to target.
444world example {{
445    export hello-world: func() -> string;
446}}
447"#,
448                ns = escape_wit(&name.namespace),
449                pkg = escape_wit(&name.name),
450            ),
451        )
452        .with_context(|| {
453            format!(
454                "failed to write targets file `{path}`",
455                path = path.display()
456            )
457        })
458    }
459
460    fn create_editor_settings_file(&self, out_dir: &Path) -> Result<()> {
461        match self.editor.as_deref() {
462            Some("vscode") | None => {
463                let settings_dir = out_dir.join(".vscode");
464                let settings_path = settings_dir.join("settings.json");
465
466                fs::create_dir_all(settings_dir)?;
467
468                fs::write(
469                    &settings_path,
470                    r#"{
471    "rust-analyzer.check.overrideCommand": [
472        "cargo",
473        "component",
474        "check",
475        "--workspace",
476        "--all-targets",
477        "--message-format=json"
478    ],
479}
480"#,
481                )
482                .with_context(|| {
483                    format!(
484                        "failed to write editor settings file `{path}`",
485                        path = settings_path.display()
486                    )
487                })
488            }
489            Some("emacs") => {
490                let settings_path = out_dir.join(".dir-locals.el");
491
492                fs::create_dir_all(out_dir)?;
493
494                fs::write(
495                    &settings_path,
496                    r#";;; Directory Local Variables
497;;; For more information see (info "(emacs) Directory Variables")
498
499((lsp-mode . ((lsp-rust-analyzer-cargo-watch-args . ["check"
500                                                     (\, "--message-format=json")])
501              (lsp-rust-analyzer-cargo-watch-command . "component")
502              (lsp-rust-analyzer-cargo-override-command . ["cargo"
503                                                           (\, "component")
504                                                           (\, "check")
505                                                           (\, "--workspace")
506                                                           (\, "--all-targets")
507                                                           (\, "--message-format=json")]))))
508"#,
509                )
510                .with_context(|| {
511                    format!(
512                        "failed to write editor settings file `{path}`",
513                        path = settings_path.display()
514                    )
515                })
516            }
517            Some("none") => Ok(()),
518            _ => unreachable!(),
519        }
520    }
521
522    /// This will always return a registry resolution if it is `Some`, but we return the
523    /// `DependencyResolution` instead so we can actually resolve the dependency.
524    async fn resolve_target(
525        &self,
526        client: Arc<CachingClient<FileCache>>,
527        target: Option<metadata::Target>,
528    ) -> Result<Option<(DependencyResolution, Option<String>)>> {
529        match target {
530            Some(metadata::Target::Package {
531                name,
532                package,
533                world,
534            }) => {
535                let mut resolver = DependencyResolver::new_with_client(client, None)?;
536                let dependency = Dependency::Package(package);
537
538                resolver.add_dependency(&name, &dependency).await?;
539
540                let dependencies = resolver.resolve().await?;
541                assert_eq!(dependencies.len(), 1);
542
543                Ok(Some((
544                    dependencies
545                        .into_values()
546                        .next()
547                        .expect("expected a target resolution"),
548                    world,
549                )))
550            }
551            _ => Ok(None),
552        }
553    }
554}