spacetimedb_cli/subcommands/generate/
mod.rs1#![warn(clippy::uninlined_format_args)]
2
3use anyhow::Context;
4use clap::parser::ValueSource;
5use clap::Arg;
6use clap::ArgAction::Set;
7use core::mem;
8use fs_err as fs;
9use spacetimedb_lib::de::serde::DeserializeWrapper;
10use spacetimedb_lib::{bsatn, RawModuleDefV8};
11use spacetimedb_lib::{RawModuleDef, MODULE_ABI_MAJOR_VERSION};
12use spacetimedb_primitives::errno;
13use spacetimedb_schema;
14use spacetimedb_schema::def::{ModuleDef, ReducerDef, ScopedTypeName, TableDef, TypeDef};
15use spacetimedb_schema::identifier::Identifier;
16use std::path::{Path, PathBuf};
17use wasmtime::{Caller, StoreContextMut};
18
19use crate::generate::util::iter_reducers;
20use crate::util::y_or_n;
21use crate::Config;
22use crate::{build, common_args};
23use clap::builder::PossibleValue;
24use std::collections::BTreeSet;
25use std::io::Read;
26use util::AUTO_GENERATED_PREFIX;
27
28mod code_indenter;
29pub mod csharp;
30pub mod rust;
31pub mod typescript;
32mod util;
33
34pub fn cli() -> clap::Command {
35 clap::Command::new("generate")
36 .about("Generate client files for a spacetime module.")
37 .override_usage("spacetime generate --lang <LANG> --out-dir <DIR> [--project-path <DIR> | --bin-path <PATH>]")
38 .arg(
39 Arg::new("wasm_file")
40 .value_parser(clap::value_parser!(PathBuf))
41 .long("bin-path")
42 .short('b')
43 .group("source")
44 .conflicts_with("project_path")
45 .conflicts_with("build_options")
46 .help("The system path (absolute or relative) to the compiled wasm binary we should inspect"),
47 )
48 .arg(
49 Arg::new("project_path")
50 .value_parser(clap::value_parser!(PathBuf))
51 .default_value(".")
52 .long("project-path")
53 .short('p')
54 .group("source")
55 .help("The system path (absolute or relative) to the project you would like to inspect"),
56 )
57 .arg(
58 Arg::new("json_module")
59 .hide(true)
60 .num_args(0..=1)
61 .value_parser(clap::value_parser!(PathBuf))
62 .long("module-def")
63 .group("source")
64 .help("Generate from a ModuleDef encoded as json"),
65 )
66 .arg(
67 Arg::new("out_dir")
68 .value_parser(clap::value_parser!(PathBuf))
69 .required(true)
70 .long("out-dir")
71 .short('o')
72 .help("The system path (absolute or relative) to the generate output directory"),
73 )
74 .arg(
75 Arg::new("namespace")
76 .default_value("SpacetimeDB.Types")
77 .long("namespace")
78 .help("The namespace that should be used"),
79 )
80 .arg(
81 Arg::new("lang")
82 .required(true)
83 .long("lang")
84 .short('l')
85 .value_parser(clap::value_parser!(Language))
86 .help("The language to generate"),
87 )
88 .arg(
89 Arg::new("build_options")
90 .long("build-options")
91 .alias("build-opts")
92 .action(Set)
93 .default_value("")
94 .help("Options to pass to the build command, for example --build-options='--lint-dir='"),
95 )
96 .arg(common_args::yes())
97 .after_help("Run `spacetime help publish` for more detailed information.")
98}
99
100pub async fn exec(config: Config, args: &clap::ArgMatches) -> 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 = if let Some(mut json_module) = json_module {
115 let DeserializeWrapper(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
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("Compiling wasm...");
131 let module = compile_wasm(&wasm_path)?;
132 spinner.set_message("Extracting schema from wasm...");
133 extract_descriptions_from_module(module)?
134 };
135
136 fs::create_dir_all(out_dir)?;
137
138 let mut paths = BTreeSet::new();
139
140 let csharp_lang;
141 let lang = match lang {
142 Language::Csharp => {
143 csharp_lang = csharp::Csharp { namespace };
144 &csharp_lang as &dyn Lang
145 }
146 Language::Rust => &rust::Rust,
147 Language::TypeScript => &typescript::TypeScript,
148 };
149
150 for (fname, code) in generate(module, lang)? {
151 let fname = Path::new(&fname);
152 if let Some(parent) = fname.parent().filter(|p| !p.as_os_str().is_empty()) {
154 fs::create_dir_all(out_dir.join(parent))?;
155 }
156 let path = out_dir.join(fname);
157 fs::write(&path, code)?;
158 paths.insert(path);
159 }
160
161 let mut auto_generated_buf: [u8; AUTO_GENERATED_PREFIX.len()] = [0; AUTO_GENERATED_PREFIX.len()];
163 let files_to_delete = walkdir::WalkDir::new(out_dir)
164 .into_iter()
165 .map(|entry_result| {
166 let entry = entry_result?;
167 if !entry.file_type().is_file() {
169 return Ok(None);
170 }
171 let path = entry.into_path();
172 if paths.contains(&path) {
174 return Ok(None);
175 }
176 let mut file = fs::File::open(&path)?;
178 Ok(match file.read_exact(&mut auto_generated_buf) {
179 Ok(()) => (auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()).then_some(path),
180 Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => None,
181 Err(err) => return Err(err.into()),
182 })
183 })
184 .filter_map(Result::transpose)
185 .collect::<anyhow::Result<Vec<_>>>()?;
186
187 if !files_to_delete.is_empty() {
188 println!("The following files were not generated by this command and will be deleted:");
189 for path in &files_to_delete {
190 println!(" {}", path.to_str().unwrap());
191 }
192
193 if y_or_n(force, "Are you sure you want to delete these files?")? {
194 for path in files_to_delete {
195 fs::remove_file(path)?;
196 }
197 println!("Files deleted successfully.");
198 } else {
199 println!("Files not deleted.");
200 }
201 }
202
203 if let Err(err) = lang.format_files(paths) {
204 eprintln!("Could not format generated files: {err}");
207 }
208
209 println!("Generate finished successfully.");
210 Ok(())
211}
212
213#[derive(Clone, Copy, PartialEq)]
214pub enum Language {
215 Csharp,
216 TypeScript,
217 Rust,
218}
219
220impl clap::ValueEnum for Language {
221 fn value_variants<'a>() -> &'a [Self] {
222 &[Self::Csharp, Self::TypeScript, Self::Rust]
223 }
224 fn to_possible_value(&self) -> Option<PossibleValue> {
225 Some(match self {
226 Self::Csharp => csharp::Csharp::clap_value(),
227 Self::TypeScript => typescript::TypeScript::clap_value(),
228 Self::Rust => rust::Rust::clap_value(),
229 })
230 }
231}
232
233pub fn generate(module: RawModuleDef, lang: &dyn Lang) -> anyhow::Result<Vec<(String, String)>> {
234 let module = &ModuleDef::try_from(module)?;
235 Ok(itertools::chain!(
236 module
237 .tables()
238 .map(|tbl| { (lang.table_filename(module, tbl), lang.generate_table(module, tbl),) }),
239 module
240 .types()
241 .map(|typ| { (lang.type_filename(&typ.name), lang.generate_type(module, typ),) }),
242 iter_reducers(module).map(|reducer| {
243 (
244 lang.reducer_filename(&reducer.name),
245 lang.generate_reducer(module, reducer),
246 )
247 }),
248 lang.generate_globals(module),
249 )
250 .collect())
251}
252
253pub trait Lang {
254 fn table_filename(&self, module: &ModuleDef, table: &TableDef) -> String;
255 fn type_filename(&self, type_name: &ScopedTypeName) -> String;
256 fn reducer_filename(&self, reducer_name: &Identifier) -> String;
257
258 fn generate_table(&self, module: &ModuleDef, tbl: &TableDef) -> String;
259 fn generate_type(&self, module: &ModuleDef, typ: &TypeDef) -> String;
260 fn generate_reducer(&self, module: &ModuleDef, reducer: &ReducerDef) -> String;
261 fn generate_globals(&self, module: &ModuleDef) -> Vec<(String, String)>;
262
263 fn format_files(&self, generated_files: BTreeSet<PathBuf>) -> anyhow::Result<()>;
264 fn clap_value() -> PossibleValue
265 where
266 Self: Sized;
267}
268
269pub fn extract_descriptions(wasm_file: &Path) -> anyhow::Result<RawModuleDef> {
270 let module = compile_wasm(wasm_file)?;
271 extract_descriptions_from_module(module)
272}
273
274fn compile_wasm(wasm_file: &Path) -> anyhow::Result<wasmtime::Module> {
275 wasmtime::Module::from_file(&wasmtime::Engine::default(), wasm_file)
276}
277
278fn extract_descriptions_from_module(module: wasmtime::Module) -> anyhow::Result<RawModuleDef> {
279 let engine = module.engine();
280 let ctx = WasmCtx {
281 mem: None,
282 sink: Vec::new(),
283 };
284 let mut store = wasmtime::Store::new(engine, ctx);
285 let mut linker = wasmtime::Linker::new(engine);
286 linker.allow_shadowing(true).define_unknown_imports_as_traps(&module)?;
287 let module_name = &*format!("spacetime_{MODULE_ABI_MAJOR_VERSION}.0");
288 linker.func_wrap(
289 module_name,
290 "console_log",
291 |mut caller: Caller<'_, WasmCtx>,
292 _level: u32,
293 _target_ptr: u32,
294 _target_len: u32,
295 _filename_ptr: u32,
296 _filename_len: u32,
297 _line_number: u32,
298 message_ptr: u32,
299 message_len: u32| {
300 let (mem, _) = WasmCtx::mem_env(&mut caller);
301 let slice = deref_slice(mem, message_ptr, message_len).unwrap();
302 println!("from wasm: {}", String::from_utf8_lossy(slice));
303 },
304 )?;
305 linker.func_wrap(module_name, "bytes_sink_write", WasmCtx::bytes_sink_write)?;
306 let instance = linker.instantiate(&mut store, &module)?;
307 let memory = instance.get_memory(&mut store, "memory").context("no memory export")?;
308 store.data_mut().mem = Some(memory);
309
310 let mut preinits = instance
311 .exports(&mut store)
312 .filter_map(|exp| Some((exp.name().strip_prefix("__preinit__")?.to_owned(), exp.into_func()?)))
313 .collect::<Vec<_>>();
314 preinits.sort_by(|(a, _), (b, _)| a.cmp(b));
315 for (_, func) in preinits {
316 func.typed(&store)?.call(&mut store, ())?
317 }
318 let module: RawModuleDef = match instance.get_func(&mut store, "__describe_module__") {
319 Some(f) => {
320 store.data_mut().sink = Vec::new();
321 f.typed::<u32, ()>(&store)?.call(&mut store, 1)?;
322 let buf = mem::take(&mut store.data_mut().sink);
323 bsatn::from_slice(&buf)?
324 }
325 None => RawModuleDef::V8BackCompat(RawModuleDefV8::default()),
327 };
328 Ok(module)
329}
330
331struct WasmCtx {
332 mem: Option<wasmtime::Memory>,
333 sink: Vec<u8>,
334}
335
336fn deref_slice(mem: &[u8], offset: u32, len: u32) -> anyhow::Result<&[u8]> {
337 anyhow::ensure!(offset != 0, "ptr is null");
338 mem.get(offset as usize..)
339 .and_then(|s| s.get(..len as usize))
340 .context("pointer out of bounds")
341}
342
343fn read_u32(mem: &[u8], offset: u32) -> anyhow::Result<u32> {
344 Ok(u32::from_le_bytes(deref_slice(mem, offset, 4)?.try_into().unwrap()))
345}
346
347impl WasmCtx {
348 pub fn get_mem(&self) -> wasmtime::Memory {
349 self.mem.expect("Initialized memory")
350 }
351
352 fn mem_env<'a>(ctx: impl Into<StoreContextMut<'a, Self>>) -> (&'a mut [u8], &'a mut Self) {
353 let ctx = ctx.into();
354 let mem = ctx.data().get_mem();
355 mem.data_and_store_mut(ctx)
356 }
357
358 pub fn bytes_sink_write(
359 mut caller: Caller<'_, Self>,
360 sink_handle: u32,
361 buffer_ptr: u32,
362 buffer_len_ptr: u32,
363 ) -> anyhow::Result<u32> {
364 if sink_handle != 1 {
365 return Ok(errno::NO_SUCH_BYTES.get().into());
366 }
367
368 let (mem, env) = Self::mem_env(&mut caller);
369
370 let buffer_len = read_u32(mem, buffer_len_ptr)?;
372 let buffer = deref_slice(mem, buffer_ptr, buffer_len)?;
374 env.sink.extend(buffer);
375
376 Ok(0)
377 }
378}