sweet_cli/commands/
automod.rs

1use beet::exports::notify::EventKind;
2use beet::exports::notify::event::ModifyKind;
3use beet::exports::notify::event::RenameMode;
4use beet::prelude::*;
5use clap::Parser;
6use quote::quote;
7use rapidhash::RapidHashMap;
8use std::path::Path;
9use std::path::PathBuf;
10use syn::File;
11use syn::Ident;
12use syn::ItemMod;
13use syn::ItemUse;
14use syn::UseTree;
15
16#[derive(Debug, Default, Clone, Parser)]
17#[command(name = "mod")]
18pub struct AutoMod {
19	#[command(flatten)]
20	pub watcher: FsWatcher,
21
22	#[arg(short, long)]
23	pub quiet: bool,
24}
25
26/// Returns whether a change was made
27#[derive(PartialEq)]
28enum DidMutate {
29	No,
30	/// For printing
31	Yes {
32		action: String,
33		path: PathBuf,
34	},
35}
36
37
38impl AutoMod {
39	pub async fn run(mut self) -> Result {
40		self.watcher.assert_path_exists()?;
41		if !self.quiet {
42			println!(
43				"🤘 sweet as 🤘\nWatching for file changes in {}",
44				self.watcher.cwd.canonicalize()?.display()
45			);
46		}
47
48		self.watcher.filter = self
49			.watcher
50			.filter
51			.with_exclude("*/tests/*")
52			.with_exclude("*/examples/*")
53			.with_exclude("*/bin/*")
54			.with_exclude("**/mod.rs")
55			.with_exclude("**/lib.rs")
56			.with_exclude("**/main.rs")
57			.with_include("**/*.rs");
58		let mut rx = self.watcher.watch()?;
59		while let Some(ev) = rx.recv().await? {
60			let mut files = ModFiles::default();
61			let any_mutated = ev
62				.iter()
63				.map(|e| self.handle_event(&mut files, e))
64				.collect::<Result<Vec<_>>>()?
65				.into_iter()
66				.filter_map(|r| match r {
67					DidMutate::No => None,
68					DidMutate::Yes { action, path } => {
69						if !self.quiet {
70							println!(
71								"AutoMod: {action} {}",
72								path_ext::relative(&path)
73									.unwrap_or(&path)
74									.display(),
75							);
76						}
77						Some(())
78					}
79				})
80				.next()
81				.is_some();
82			if any_mutated {
83				files.write_all()?;
84			}
85		}
86		Ok(())
87	}
88
89
90	fn handle_event(
91		&self,
92		files: &mut ModFiles,
93		e: &WatchEvent,
94	) -> Result<DidMutate> {
95		enum Step {
96			Insert,
97			Remove,
98		}
99
100		// let (parent_mod, mod_file) = Self::insert_mod(&e.path)?;
101		// self.write_file("insert", &e.path, parent_mod, mod_file)?;
102
103		let step = match e.kind {
104			EventKind::Create(_)
105			| EventKind::Modify(ModifyKind::Name(RenameMode::To)) => Step::Insert,
106			EventKind::Remove(_)
107			| EventKind::Modify(ModifyKind::Name(RenameMode::From)) => Step::Remove,
108			EventKind::Modify(ModifyKind::Name(_))
109			| EventKind::Modify(ModifyKind::Data(_)) => {
110				if e.path.exists() {
111					Step::Insert
112				} else {
113					Step::Remove
114				}
115			}
116			_ => {
117				return Ok(DidMutate::No);
118			}
119		};
120
121		let file_meta = FileMeta::new(&e.path)?;
122		let file = files.get_mut(&file_meta.parent_mod)?;
123		match step {
124			Step::Insert => Self::insert_mod(file, file_meta),
125			Step::Remove => Self::remove_mod(file, file_meta),
126		}
127	}
128
129	/// Load the parents `mod.rs` or `lib.rs` file and insert a new module
130	fn insert_mod(
131		mod_file: &mut File,
132		FileMeta {
133			is_lib_dir,
134			file_stem,
135			mod_ident,
136			event_path,
137			..
138		}: FileMeta,
139	) -> Result<DidMutate> {
140		for item in &mut mod_file.items {
141			if let syn::Item::Mod(m) = item {
142				if m.ident == file_stem {
143					// module already exists, nothing to do here
144					return Ok(DidMutate::No);
145				}
146			}
147		}
148
149		let vis = if is_lib_dir {
150			quote! {pub}
151		} else {
152			Default::default()
153		};
154
155
156		let insert_pos = mod_file
157			.items
158			.iter()
159			.position(|item| matches!(item, syn::Item::Mod(_)))
160			.unwrap_or(mod_file.items.len());
161
162		let mod_def: ItemMod = syn::parse_quote!(#vis mod #mod_ident;);
163		mod_file.items.insert(insert_pos, mod_def.into());
164
165		if is_lib_dir {
166			// export in prelude
167			for item in &mut mod_file.items {
168				if let syn::Item::Mod(m) = item {
169					if m.ident == "prelude" {
170						if let Some(content) = m.content.as_mut() {
171							content.1.push(
172								syn::parse_quote!(pub use crate::#mod_ident::*;),
173							);
174						} else {
175							m.content =
176								Some((syn::token::Brace::default(), vec![
177									syn::parse_quote!(pub use crate::#mod_ident::*;),
178								]));
179						}
180						break;
181					}
182				}
183			}
184		} else {
185			// export at root
186			mod_file.items.insert(
187				insert_pos + 1,
188				syn::parse_quote!(pub use #mod_ident::*;),
189			);
190		}
191
192		Ok(DidMutate::Yes {
193			action: "insert".into(),
194			path: event_path.to_path_buf(),
195		})
196	}
197
198	fn remove_mod(
199		mod_file: &mut File,
200		FileMeta {
201			is_lib_dir,
202			file_stem,
203			mod_ident,
204			event_path,
205			..
206		}: FileMeta,
207	) -> Result<DidMutate> {
208		let mut did_mutate = false;
209		mod_file.items.retain(|item| {
210			if let syn::Item::Mod(m) = item {
211				if m.ident == file_stem {
212					did_mutate = true;
213					return false;
214				}
215			}
216			true
217		});
218
219		// Remove the re-export
220		if is_lib_dir {
221			// Remove from prelude
222			for item in &mut mod_file.items {
223				if let syn::Item::Mod(m) = item {
224					if m.ident == "prelude" {
225						if let Some(content) = m.content.as_mut() {
226							content.1.retain(|item| {
227								if let syn::Item::Use(use_item) = item {
228									if let Some(last) = use_item_ident(use_item)
229									{
230										if last == &mod_ident {
231											did_mutate = true;
232											return false;
233										}
234									}
235								}
236								true
237							});
238						}
239						break;
240					}
241				}
242			}
243		} else {
244			// Remove re-export at root
245			mod_file.items.retain(|item| {
246				if let syn::Item::Use(use_item) = item {
247					if let Some(last) = use_item_ident(use_item) {
248						if last == &mod_ident {
249							did_mutate = true;
250							return false;
251						}
252					}
253				}
254				true
255			});
256		}
257
258		Ok(match did_mutate {
259			true => DidMutate::Yes {
260				action: "remove".into(),
261				path: event_path.to_path_buf(),
262			},
263			false => DidMutate::No,
264		})
265	}
266}
267/// find the first part of an ident, skiping `crate`, `super` or `self`
268fn use_item_ident(use_item: &ItemUse) -> Option<&Ident> {
269	const SKIP: [&str; 3] = ["crate", "super", "self"];
270	match &use_item.tree {
271		UseTree::Path(use_path) => {
272			if SKIP.contains(&use_path.ident.to_string().as_str()) {
273				match &*use_path.tree {
274					UseTree::Path(use_path) => {
275						return Some(&use_path.ident);
276					}
277					UseTree::Name(use_name) => {
278						return Some(&use_name.ident);
279					}
280					_ => {}
281				}
282			} else {
283				return Some(&use_path.ident);
284			}
285		}
286		_ => {}
287	}
288	None
289}
290
291#[derive(Default, Clone)]
292struct ModFiles {
293	map: RapidHashMap<PathBuf, File>,
294}
295
296impl ModFiles {
297	/// Get a mutable reference to the file at the given path.
298	/// If it doesnt exist, an empty file is created, and will be
299	/// written to disk on [`ModFiles::write_all`].
300	pub fn get_mut(&mut self, path: impl AsRef<Path>) -> Result<&mut File> {
301		let path = path.as_ref();
302		if !self.map.contains_key(path) {
303			// if it doesnt exist create an empty file
304			let file = fs_ext::read_to_string(path).unwrap_or_default();
305			let file = syn::parse_file(&file)?;
306			self.map.insert(path.to_path_buf(), file);
307		}
308		Ok(self.map.get_mut(path).unwrap())
309	}
310	pub fn write_all(&self) -> Result {
311		// TODO only perform write if hash changed
312		for (path, file) in &self.map {
313			let file = prettyplease::unparse(file);
314			fs_ext::write(path, &file)?;
315			println!(
316				"AutoMod: write  {}",
317				path_ext::relative(path).unwrap_or(path).display()
318			);
319		}
320		Ok(())
321	}
322}
323
324struct FileMeta<'a> {
325	pub is_lib_dir: bool,
326	pub parent_mod: PathBuf,
327	pub file_stem: String,
328	#[allow(dead_code)]
329	pub event_path: &'a Path,
330	pub mod_ident: syn::Ident,
331}
332
333impl<'a> FileMeta<'a> {
334	/// Returns either `lib.rs` or `mod.rs` for the given path's parent
335	fn new(event_path: &'a Path) -> Result<Self> {
336		let Some(parent) = event_path.parent() else {
337			bevybail!("No parent found for path {}", event_path.display());
338		};
339		let is_lib_dir =
340			parent.file_name().map(|f| f == "src").unwrap_or(false);
341		let parent_mod = if is_lib_dir {
342			parent.join("lib.rs")
343		} else {
344			parent.join("mod.rs")
345		};
346		let Some(file_stem) = event_path
347			.file_stem()
348			.map(|s| s.to_string_lossy().to_string())
349		else {
350			bevybail!("No file stem found for path {}", event_path.display());
351		};
352
353		let mod_ident =
354			syn::Ident::new(&file_stem, proc_macro2::Span::call_site());
355
356		Ok(Self {
357			event_path,
358			is_lib_dir,
359			parent_mod,
360			file_stem,
361			mod_ident,
362		})
363	}
364}
365
366#[cfg(test)]
367mod test {
368	use super::*;
369	use sweet::prelude::*;
370
371	#[test]
372	fn insert_works() {
373		fn insert(ws_path: impl AsRef<Path>) -> Result<String> {
374			let abs =
375				AbsPathBuf::new(fs_ext::workspace_root().join(ws_path.as_ref()))
376					.unwrap();
377			let file_meta = FileMeta::new(abs.as_ref())?;
378			let file = fs_ext::read_to_string(&file_meta.parent_mod)?;
379			let mut file = syn::parse_file(&file)?;
380			AutoMod::insert_mod(&mut file, file_meta)?;
381			let file = prettyplease::unparse(&file);
382			Ok(file)
383		}
384
385		let insert_lib = insert("crates/sweet/cli/src/foo.rs").unwrap();
386		(&insert_lib).xpect_contains("pub mod foo;");
387		(&insert_lib).xpect_contains("pub use crate::foo::*;");
388
389		let insert_mod =
390			insert("crates/sweet/cli/src/commands/foo.rs").unwrap();
391		(&insert_mod).xpect_contains("mod foo;");
392		(&insert_mod).xpect_contains("pub use foo::*;");
393	}
394	#[test]
395	fn remove_works() {
396		fn remove(ws_path: impl AsRef<Path>) -> Result<String> {
397			let abs =
398				AbsPathBuf::new(fs_ext::workspace_root().join(ws_path.as_ref()))
399					.unwrap();
400			let file_meta = FileMeta::new(abs.as_ref())?;
401			let file = fs_ext::read_to_string(&file_meta.parent_mod)?;
402			let mut file = syn::parse_file(&file)?;
403			AutoMod::remove_mod(&mut file, file_meta)?;
404			let file = prettyplease::unparse(&file);
405			Ok(file)
406		}
407
408		let remove_lib = remove("crates/sweet/cli/src/automod").unwrap();
409		(&remove_lib).xnot().xpect_contains("pub mod automod;");
410		(&remove_lib)
411			.xnot()
412			.xpect_contains("pub use crate::automod::*;");
413
414
415		let remove_mod =
416			remove("crates/sweet/cli/src/commands/automod.rs").unwrap();
417		(&remove_mod).xnot().xpect_contains("pub mod automod;");
418		(&remove_mod).xnot().xpect_contains("pub use automod::*;");
419	}
420}