spacetimedb_cli/subcommands/
generate.rs

1#![warn(clippy::uninlined_format_args)]
2
3use anyhow::Context;
4use clap::parser::ValueSource;
5use clap::Arg;
6use clap::ArgAction::Set;
7use fs_err as fs;
8use spacetimedb_codegen::{generate, Csharp, Lang, Rust, TypeScript, AUTO_GENERATED_PREFIX};
9use spacetimedb_lib::de::serde::DeserializeWrapper;
10use spacetimedb_lib::{sats, RawModuleDef};
11use spacetimedb_schema;
12use spacetimedb_schema::def::ModuleDef;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15
16use crate::tasks::csharp::dotnet_format;
17use crate::tasks::rust::rustfmt;
18use crate::util::{resolve_sibling_binary, y_or_n};
19use crate::Config;
20use crate::{build, common_args};
21use clap::builder::PossibleValue;
22use std::collections::BTreeSet;
23use std::io::Read;
24
25pub fn cli() -> clap::Command {
26    clap::Command::new("generate")
27        .about("Generate client files for a spacetime module.")
28        .override_usage("spacetime generate --lang <LANG> --out-dir <DIR> [--project-path <DIR> | --bin-path <PATH>]")
29        .arg(
30            Arg::new("wasm_file")
31                .value_parser(clap::value_parser!(PathBuf))
32                .long("bin-path")
33                .short('b')
34                .group("source")
35                .conflicts_with("project_path")
36                .conflicts_with("build_options")
37                .help("The system path (absolute or relative) to the compiled wasm binary we should inspect"),
38        )
39        .arg(
40            Arg::new("project_path")
41                .value_parser(clap::value_parser!(PathBuf))
42                .default_value(".")
43                .long("project-path")
44                .short('p')
45                .group("source")
46                .help("The system path (absolute or relative) to the project you would like to inspect"),
47        )
48        .arg(
49            Arg::new("json_module")
50                .hide(true)
51                .num_args(0..=1)
52                .value_parser(clap::value_parser!(PathBuf))
53                .long("module-def")
54                .group("source")
55                .help("Generate from a ModuleDef encoded as json"),
56        )
57        .arg(
58            Arg::new("out_dir")
59                .value_parser(clap::value_parser!(PathBuf))
60                .required(true)
61                .long("out-dir")
62                .short('o')
63                .help("The system path (absolute or relative) to the generate output directory"),
64        )
65        .arg(
66            Arg::new("namespace")
67                .default_value("SpacetimeDB.Types")
68                .long("namespace")
69                .help("The namespace that should be used"),
70        )
71        .arg(
72            Arg::new("lang")
73                .required(true)
74                .long("lang")
75                .short('l')
76                .value_parser(clap::value_parser!(Language))
77                .help("The language to generate"),
78        )
79        .arg(
80            Arg::new("build_options")
81                .long("build-options")
82                .alias("build-opts")
83                .action(Set)
84                .default_value("")
85                .help("Options to pass to the build command, for example --build-options='--lint-dir='"),
86        )
87        .arg(common_args::yes())
88        .after_help("Run `spacetime help publish` for more detailed information.")
89}
90
91pub async fn exec(config: Config, args: &clap::ArgMatches) -> anyhow::Result<()> {
92    exec_ex(config, args, extract_descriptions).await
93}
94
95/// Like `exec`, but lets you specify a custom a function to extract a schema from a file.
96pub async fn exec_ex(
97    config: Config,
98    args: &clap::ArgMatches,
99    extract_descriptions: ExtractDescriptions,
100) -> anyhow::Result<()> {
101    let project_path = args.get_one::<PathBuf>("project_path").unwrap();
102    let wasm_file = args.get_one::<PathBuf>("wasm_file").cloned();
103    let json_module = args.get_many::<PathBuf>("json_module");
104    let out_dir = args.get_one::<PathBuf>("out_dir").unwrap();
105    let lang = *args.get_one::<Language>("lang").unwrap();
106    let namespace = args.get_one::<String>("namespace").unwrap();
107    let force = args.get_flag("force");
108    let build_options = args.get_one::<String>("build_options").unwrap();
109
110    if args.value_source("namespace") == Some(ValueSource::CommandLine) && lang != Language::Csharp {
111        return Err(anyhow::anyhow!("--namespace is only supported with --lang csharp"));
112    }
113
114    let module: ModuleDef = if let Some(mut json_module) = json_module {
115        let DeserializeWrapper::<RawModuleDef>(module) = if let Some(path) = json_module.next() {
116            serde_json::from_slice(&fs::read(path)?)?
117        } else {
118            serde_json::from_reader(std::io::stdin().lock())?
119        };
120        module.try_into()?
121    } else {
122        let wasm_path = if let Some(path) = wasm_file {
123            println!("Skipping build. Instead we are inspecting {}", path.display());
124            path.clone()
125        } else {
126            build::exec_with_argstring(config.clone(), project_path, build_options).await?
127        };
128        let spinner = indicatif::ProgressBar::new_spinner();
129        spinner.enable_steady_tick(std::time::Duration::from_millis(60));
130        spinner.set_message("Extracting schema from wasm...");
131        extract_descriptions(&wasm_path).context("could not extract schema")?
132    };
133
134    fs::create_dir_all(out_dir)?;
135
136    let mut paths = BTreeSet::new();
137
138    let csharp_lang;
139    let gen_lang = match lang {
140        Language::Csharp => {
141            csharp_lang = Csharp { namespace };
142            &csharp_lang as &dyn Lang
143        }
144        Language::Rust => &Rust,
145        Language::TypeScript => &TypeScript,
146    };
147
148    for (fname, code) in generate(&module, gen_lang) {
149        let fname = Path::new(&fname);
150        // If a generator asks for a file in a subdirectory, create the subdirectory first.
151        if let Some(parent) = fname.parent().filter(|p| !p.as_os_str().is_empty()) {
152            fs::create_dir_all(out_dir.join(parent))?;
153        }
154        let path = out_dir.join(fname);
155        fs::write(&path, code)?;
156        paths.insert(path);
157    }
158
159    // TODO: We should probably just delete all generated files before we generate any, rather than selectively deleting some afterward.
160    let mut auto_generated_buf: [u8; AUTO_GENERATED_PREFIX.len()] = [0; AUTO_GENERATED_PREFIX.len()];
161    let files_to_delete = walkdir::WalkDir::new(out_dir)
162        .into_iter()
163        .map(|entry_result| {
164            let entry = entry_result?;
165            // Only delete files.
166            if !entry.file_type().is_file() {
167                return Ok(None);
168            }
169            let path = entry.into_path();
170            // Don't delete regenerated files.
171            if paths.contains(&path) {
172                return Ok(None);
173            }
174            // Only delete files that start with the auto-generated prefix.
175            let mut file = fs::File::open(&path)?;
176            Ok(match file.read_exact(&mut auto_generated_buf) {
177                Ok(()) => (auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()).then_some(path),
178                Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => None,
179                Err(err) => return Err(err.into()),
180            })
181        })
182        .filter_map(Result::transpose)
183        .collect::<anyhow::Result<Vec<_>>>()?;
184
185    if !files_to_delete.is_empty() {
186        println!("The following files were not generated by this command and will be deleted:");
187        for path in &files_to_delete {
188            println!("  {}", path.to_str().unwrap());
189        }
190
191        if y_or_n(force, "Are you sure you want to delete these files?")? {
192            for path in files_to_delete {
193                fs::remove_file(path)?;
194            }
195            println!("Files deleted successfully.");
196        } else {
197            println!("Files not deleted.");
198        }
199    }
200
201    if let Err(err) = lang.format_files(paths) {
202        // If we couldn't format the files, print a warning but don't fail the entire
203        // task as the output should still be usable, just less pretty.
204        eprintln!("Could not format generated files: {err}");
205    }
206
207    println!("Generate finished successfully.");
208    Ok(())
209}
210
211#[derive(Clone, Copy, PartialEq)]
212pub enum Language {
213    Csharp,
214    TypeScript,
215    Rust,
216}
217
218impl clap::ValueEnum for Language {
219    fn value_variants<'a>() -> &'a [Self] {
220        &[Self::Csharp, Self::TypeScript, Self::Rust]
221    }
222    fn to_possible_value(&self) -> Option<PossibleValue> {
223        Some(match self {
224            Self::Csharp => clap::builder::PossibleValue::new("csharp").aliases(["c#", "cs"]),
225            Self::TypeScript => clap::builder::PossibleValue::new("typescript").aliases(["ts", "TS"]),
226            Self::Rust => clap::builder::PossibleValue::new("rust").aliases(["rs", "RS"]),
227        })
228    }
229}
230
231impl Language {
232    fn format_files(&self, generated_files: BTreeSet<PathBuf>) -> anyhow::Result<()> {
233        match self {
234            Language::Rust => rustfmt(generated_files)?,
235            Language::Csharp => dotnet_format(generated_files)?,
236            Language::TypeScript => {
237                // TODO: implement formatting.
238            }
239        }
240
241        Ok(())
242    }
243}
244
245pub type ExtractDescriptions = fn(&Path) -> anyhow::Result<ModuleDef>;
246fn extract_descriptions(wasm_file: &Path) -> anyhow::Result<ModuleDef> {
247    let bin_path = resolve_sibling_binary("spacetimedb-standalone")?;
248    let child = Command::new(&bin_path)
249        .arg("extract-schema")
250        .arg(wasm_file)
251        .stdout(Stdio::piped())
252        .spawn()
253        .with_context(|| format!("failed to spawn {}", bin_path.display()))?;
254    let sats::serde::SerdeWrapper::<RawModuleDef>(module) = serde_json::from_reader(child.stdout.unwrap())?;
255    Ok(module.try_into()?)
256}