sweet_cli/commands/
automod.rs

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