sweet_cli/automod/
mod.rs

1use clap::Parser;
2use quote::quote;
3use rapidhash::RapidHashMap;
4use std::path::Path;
5use std::path::PathBuf;
6use sweet::fs::exports::notify::EventKind;
7use sweet::fs::exports::notify::event::ModifyKind;
8use sweet::fs::exports::notify::event::RenameMode;
9use sweet::prelude::*;
10use syn::File;
11use syn::Ident;
12use syn::ItemMod;
13use syn::ItemUse;
14use syn::UseTree;
15
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.infallible = true;
50		self.watcher.filter = self
51			.watcher
52			.filter
53			.with_exclude("**/mod.rs")
54			.with_exclude("**/lib.rs")
55			.with_include("**/*.rs");
56		self.watcher
57			.watch_async(|e| {
58				let mut files = ModFiles::default();
59				let any_mutated = e
60					.events
61					.iter()
62					.map(|e| self.handle_event(&mut files, e))
63					.collect::<Result<Vec<_>>>()?
64					.into_iter()
65					.filter_map(|r| match r {
66						DidMutate::No => None,
67						DidMutate::Yes { action, path } => {
68							if !self.quiet {
69								println!(
70									"AutoMod: {action} {}",
71									PathExt::relative(&path)
72										.unwrap_or(&path)
73										.display(),
74								);
75							}
76							Some(())
77						}
78					})
79					.next()
80					.is_some();
81				if any_mutated {
82					files.write_all()?;
83				}
84				Ok(())
85			})
86			.await?;
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
374	#[test]
375	fn insert_works() {
376		fn insert(workspace_path: impl AsRef<Path>) -> Result<String> {
377			let abs = AbsPathBuf::new_unchecked(
378				FsExt::workspace_root().join(workspace_path.as_ref()),
379			);
380			let file_meta = FileMeta::new(abs.as_ref())?;
381			let file = ReadFile::to_string(&file_meta.parent_mod)?;
382			let mut file = syn::parse_file(&file)?;
383			AutoMod::insert_mod(&mut file, file_meta)?;
384			let file = prettyplease::unparse(&file);
385			Ok(file)
386		}
387
388		let insert_lib = insert("crates/sweet-cli/src/foo.rs").unwrap();
389		expect(&insert_lib).to_contain("pub mod foo;");
390		expect(&insert_lib).to_contain("pub use crate::foo::*;");
391
392		let insert_mod = insert("crates/sweet-cli/src/bench/foo.rs").unwrap();
393		expect(&insert_mod).to_contain("mod foo;");
394		expect(&insert_mod).to_contain("pub use foo::*;");
395	}
396	#[test]
397	fn remove_works() {
398		fn remove(workspace_path: impl AsRef<Path>) -> Result<String> {
399			let abs = AbsPathBuf::new_unchecked(
400				FsExt::workspace_root().join(workspace_path.as_ref()),
401			);
402			let file_meta = FileMeta::new(abs.as_ref())?;
403			let file = ReadFile::to_string(&file_meta.parent_mod)?;
404			let mut file = syn::parse_file(&file)?;
405			AutoMod::remove_mod(&mut file, file_meta)?;
406			let file = prettyplease::unparse(&file);
407			Ok(file)
408		}
409
410		let remove_lib = remove("crates/sweet-cli/src/automod").unwrap();
411		expect(&remove_lib).not().to_contain("pub mod automod;");
412		expect(&remove_lib)
413			.not()
414			.to_contain("pub use crate::automod::*;");
415
416
417		let remove_mod =
418			remove("crates/sweet-cli/src/bench/bench_assert.rs").unwrap();
419		expect(&remove_mod)
420			.not()
421			.to_contain("pub mod bench_assert;");
422		expect(&remove_mod)
423			.not()
424			.to_contain("pub use bench_assert::*;");
425	}
426}