Skip to content

Commit 6248fe1

Browse files
refactor: data-driven backend project templates (#3894)
Part of https://dfinity.atlassian.net/browse/SDK-1805
1 parent 78c960b commit 6248fe1

File tree

9 files changed

+214
-47
lines changed

9 files changed

+214
-47
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/tests-dfx/new.bash

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ teardown() {
3535
assert_command_fail dfx new 'a:b'
3636
}
3737

38+
@test "dfx new --help shows possible backend template names" {
39+
assert_command dfx new --help
40+
assert_match "\[possible values.*motoko.*\]"
41+
assert_match "\[possible values.*rust.*\]"
42+
assert_match "\[possible values.*kybra.*\]"
43+
assert_match "\[possible values.*azle.*\]"
44+
}
45+
46+
@test "dfx new --type <bad type> shows possible values" {
47+
assert_command_fail dfx new --type bad_type
48+
assert_match "\[possible values.*motoko.*\]"
49+
assert_match "\[possible values.*rust.*\]"
50+
assert_match "\[possible values.*kybra.*\]"
51+
assert_match "\[possible values.*azle.*\]"
52+
}
53+
3854
@test "dfx new readmes contain appropriate links" {
3955
assert_command dfx new --type rust e2e_rust --no-frontend
4056
assert_command grep "https://docs.rs/ic-cdk" e2e_rust/README.md

src/dfx-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ humantime-serde = "1.1.1"
2626
ic-agent.workspace = true
2727
ic-utils.workspace = true
2828
ic-identity-hsm.workspace = true
29+
itertools.workspace = true
2930
k256 = { version = "0.11.4", features = ["pem"] }
3031
keyring.workspace = true
3132
lazy_static.workspace = true

src/dfx-core/src/config/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod cache;
22
pub mod directories;
33
pub mod model;
4+
pub mod project_templates;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use itertools::Itertools;
2+
use std::collections::BTreeMap;
3+
use std::io;
4+
use std::sync::OnceLock;
5+
6+
type GetArchiveFn = fn() -> Result<tar::Archive<flate2::read::GzDecoder<&'static [u8]>>, io::Error>;
7+
8+
#[derive(Debug, Clone)]
9+
pub enum ResourceLocation {
10+
/// The template's assets are compiled into the dfx binary
11+
Bundled { get_archive_fn: GetArchiveFn },
12+
}
13+
14+
#[derive(Debug, Clone, Eq, PartialEq)]
15+
pub enum Category {
16+
Backend,
17+
}
18+
19+
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
20+
pub struct ProjectTemplateName(pub String);
21+
22+
#[derive(Debug, Clone)]
23+
pub struct ProjectTemplate {
24+
/// The name of the template as specified on the command line,
25+
/// for example `--type rust`
26+
pub name: ProjectTemplateName,
27+
28+
/// The name used for display and sorting
29+
pub display: String,
30+
31+
/// How to obtain the template's files
32+
pub resource_location: ResourceLocation,
33+
34+
/// Used to determine which CLI group (`--type`, `--backend`, `--frontend`)
35+
/// as well as for interactive selection
36+
pub category: Category,
37+
38+
/// If true, run `cargo update` after creating the project
39+
pub update_cargo_lockfile: bool,
40+
41+
/// If true, patch in the any_js template files
42+
pub has_js: bool,
43+
44+
/// The sort order is fixed rather than settable in properties:
45+
/// - motoko=0
46+
/// - rust=1
47+
/// - everything else=2 (and then by display name)
48+
pub sort_order: u32,
49+
}
50+
51+
type ProjectTemplates = BTreeMap<ProjectTemplateName, ProjectTemplate>;
52+
53+
static PROJECT_TEMPLATES: OnceLock<ProjectTemplates> = OnceLock::new();
54+
55+
pub fn populate(builtin_templates: Vec<ProjectTemplate>) {
56+
let templates = builtin_templates
57+
.iter()
58+
.map(|t| (t.name.clone(), t.clone()))
59+
.collect();
60+
61+
PROJECT_TEMPLATES.set(templates).unwrap();
62+
}
63+
64+
pub fn get_project_template(name: &ProjectTemplateName) -> ProjectTemplate {
65+
PROJECT_TEMPLATES.get().unwrap().get(name).cloned().unwrap()
66+
}
67+
68+
pub fn get_sorted_templates(category: Category) -> Vec<ProjectTemplate> {
69+
PROJECT_TEMPLATES
70+
.get()
71+
.unwrap()
72+
.values()
73+
.filter(|t| t.category == category)
74+
.cloned()
75+
.sorted_by(|a, b| {
76+
a.sort_order
77+
.cmp(&b.sort_order)
78+
.then_with(|| a.display.cmp(&b.display))
79+
})
80+
.collect()
81+
}
82+
83+
pub fn project_template_cli_names(category: Category) -> Vec<String> {
84+
PROJECT_TEMPLATES
85+
.get()
86+
.unwrap()
87+
.values()
88+
.filter(|t| t.category == category)
89+
.map(|t| t.name.0.clone())
90+
.collect()
91+
}

src/dfx/src/commands/new.rs

Lines changed: 39 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ use crate::lib::program;
77
use crate::util::assets;
88
use crate::util::clap::parsers::project_name_parser;
99
use anyhow::{anyhow, bail, ensure, Context};
10+
use clap::builder::PossibleValuesParser;
1011
use clap::{Parser, ValueEnum};
1112
use console::{style, Style};
13+
use dfx_core::config::project_templates::{
14+
get_project_template, get_sorted_templates, project_template_cli_names, Category,
15+
ProjectTemplateName, ResourceLocation,
16+
};
1217
use dfx_core::json::{load_json_file, save_json_file};
1318
use dialoguer::theme::ColorfulTheme;
1419
use dialoguer::{FuzzySelect, MultiSelect};
@@ -38,6 +43,8 @@ const AGENT_JS_DEFAULT_INSTALL_DIST_TAG: &str = "latest";
3843
// check.
3944
const CHECK_VERSION_TIMEOUT: Duration = Duration::from_secs(2);
4045

46+
const BACKEND_MOTOKO: &str = "motoko";
47+
4148
/// Creates a new project.
4249
#[derive(Parser)]
4350
pub struct NewOpts {
@@ -46,8 +53,8 @@ pub struct NewOpts {
4653
project_name: String,
4754

4855
/// Choose the type of canister in the starter project.
49-
#[arg(long, value_enum)]
50-
r#type: Option<BackendType>,
56+
#[arg(long, value_parser=backend_project_template_name_parser())]
57+
r#type: Option<String>,
5158

5259
/// Provides a preview the directories and files to be created without adding them to the file system.
5360
#[arg(long)]
@@ -70,24 +77,8 @@ pub struct NewOpts {
7077
extras: Vec<Extra>,
7178
}
7279

73-
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
74-
enum BackendType {
75-
Motoko,
76-
Rust,
77-
Azle,
78-
Kybra,
79-
}
80-
81-
impl Display for BackendType {
82-
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
83-
match self {
84-
Self::Motoko => "Motoko",
85-
Self::Rust => "Rust",
86-
Self::Azle => "TypeScript (Azle)",
87-
Self::Kybra => "Python (Kybra)",
88-
}
89-
.fmt(f)
90-
}
80+
fn backend_project_template_name_parser() -> PossibleValuesParser {
81+
PossibleValuesParser::new(project_template_cli_names(Category::Backend))
9182
}
9283

9384
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
@@ -466,19 +457,17 @@ fn get_agent_js_version_from_npm(dist_tag: &str) -> DfxResult<String> {
466457
}
467458

468459
pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
469-
use BackendType::*;
470460
let log = env.get_logger();
471461
let dry_run = opts.dry_run;
472462

473-
let r#type = if let Some(r#type) = opts.r#type {
474-
r#type
463+
let backend_template_name = if let Some(r#type) = opts.r#type {
464+
ProjectTemplateName(r#type)
475465
} else if opts.frontend.is_none() && opts.extras.is_empty() && io::stdout().is_terminal() {
476466
opts = get_opts_interactively(opts)?;
477-
opts.r#type.unwrap()
467+
ProjectTemplateName(opts.r#type.unwrap())
478468
} else {
479-
Motoko
469+
ProjectTemplateName(BACKEND_MOTOKO.to_string())
480470
};
481-
482471
let project_name = Path::new(opts.project_name.as_str());
483472
if project_name.exists() {
484473
bail!("Cannot create a new project because the directory already exists.");
@@ -560,7 +549,9 @@ pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
560549
opts.frontend.unwrap_or(FrontendType::Vanilla)
561550
};
562551

563-
if r#type == Azle || frontend.has_js() {
552+
let backend = get_project_template(&backend_template_name);
553+
554+
if backend.has_js || frontend.has_js() {
564555
write_files_from_entries(
565556
log,
566557
&mut assets::new_project_js_files().context("Failed to get JS config archive.")?,
@@ -570,20 +561,19 @@ pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
570561
)?;
571562
}
572563

573-
// Default to start with motoko
574-
let mut new_project_files = match r#type {
575-
Rust => assets::new_project_rust_files().context("Failed to get rust archive.")?,
576-
Motoko => assets::new_project_motoko_files().context("Failed to get motoko archive.")?,
577-
Azle => assets::new_project_azle_files().context("Failed to get azle archive.")?,
578-
Kybra => assets::new_project_kybra_files().context("Failed to get kybra archive.")?,
564+
match backend.resource_location {
565+
ResourceLocation::Bundled { get_archive_fn } => {
566+
let mut new_project_files = get_archive_fn()
567+
.with_context(|| format!("Failed to get {} archive.", backend.name.0))?;
568+
write_files_from_entries(
569+
log,
570+
&mut new_project_files,
571+
project_name,
572+
dry_run,
573+
&variables,
574+
)?;
575+
}
579576
};
580-
write_files_from_entries(
581-
log,
582-
&mut new_project_files,
583-
project_name,
584-
dry_run,
585-
&variables,
586-
)?;
587577

588578
if opts.extras.contains(&Extra::InternetIdentity) {
589579
write_files_from_entries(
@@ -645,7 +635,7 @@ pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
645635
init_git(log, project_name)?;
646636
}
647637

648-
if r#type == Rust {
638+
if backend.update_cargo_lockfile {
649639
// dfx build will use --locked, so update the lockfile beforehand
650640
const MSG: &str = "You will need to run it yourself (or a similar command like `cargo vendor`), because `dfx build` will use the --locked flag with Cargo.";
651641
if let Ok(code) = Command::new("cargo")
@@ -683,17 +673,21 @@ pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
683673
}
684674

685675
fn get_opts_interactively(opts: NewOpts) -> DfxResult<NewOpts> {
686-
use BackendType::*;
687676
use Extra::*;
688677
use FrontendType::*;
689678
let theme = ColorfulTheme::default();
690-
let backends_list = [Motoko, Rust, Azle, Kybra];
679+
let backend_templates = get_sorted_templates(Category::Backend);
680+
let backends_list = backend_templates
681+
.iter()
682+
.map(|t| t.display.clone())
683+
.collect::<Vec<_>>();
684+
691685
let backend = FuzzySelect::with_theme(&theme)
692686
.items(&backends_list)
693687
.default(0)
694688
.with_prompt("Select a backend language:")
695689
.interact()?;
696-
let backend = backends_list[backend];
690+
let backend = &backend_templates[backend];
697691
let frontends_list = [SvelteKit, React, Vue, Vanilla, SimpleAssets, None];
698692
let frontend = FuzzySelect::with_theme(&theme)
699693
.items(&frontends_list)
@@ -715,7 +709,7 @@ fn get_opts_interactively(opts: NewOpts) -> DfxResult<NewOpts> {
715709
let opts = NewOpts {
716710
extras,
717711
frontend: Some(frontend),
718-
r#type: Some(backend),
712+
r#type: Some(backend.name.0.clone()),
719713
..opts
720714
};
721715
Ok(opts)

src/dfx/src/lib/project/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod import;
22
pub mod network_mappings;
3+
pub mod templates;

src/dfx/src/lib/project/templates.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use crate::util::assets;
2+
use dfx_core::config::project_templates::{
3+
Category, ProjectTemplate, ProjectTemplateName, ResourceLocation,
4+
};
5+
6+
pub fn builtin_templates() -> Vec<ProjectTemplate> {
7+
let motoko = ProjectTemplate {
8+
name: ProjectTemplateName("motoko".to_string()),
9+
display: "Motoko".to_string(),
10+
resource_location: ResourceLocation::Bundled {
11+
get_archive_fn: assets::new_project_motoko_files,
12+
},
13+
category: Category::Backend,
14+
sort_order: 0,
15+
update_cargo_lockfile: false,
16+
has_js: false,
17+
};
18+
19+
let rust = ProjectTemplate {
20+
name: ProjectTemplateName("rust".to_string()),
21+
display: "Rust".to_string(),
22+
resource_location: ResourceLocation::Bundled {
23+
get_archive_fn: assets::new_project_rust_files,
24+
},
25+
category: Category::Backend,
26+
sort_order: 1,
27+
update_cargo_lockfile: true,
28+
has_js: false,
29+
};
30+
31+
let azle = ProjectTemplate {
32+
name: ProjectTemplateName("azle".to_string()),
33+
display: "Typescript (Azle)".to_string(),
34+
resource_location: ResourceLocation::Bundled {
35+
get_archive_fn: assets::new_project_azle_files,
36+
},
37+
category: Category::Backend,
38+
sort_order: 2,
39+
update_cargo_lockfile: false,
40+
has_js: true,
41+
};
42+
43+
let kybra = ProjectTemplate {
44+
name: ProjectTemplateName("kybra".to_string()),
45+
display: "Python (Kybra)".to_string(),
46+
resource_location: ResourceLocation::Bundled {
47+
get_archive_fn: assets::new_project_kybra_files,
48+
},
49+
category: Category::Backend,
50+
sort_order: 2,
51+
update_cargo_lockfile: false,
52+
has_js: false,
53+
};
54+
55+
vec![motoko, rust, azle, kybra]
56+
}

0 commit comments

Comments
 (0)