sweet_cli/commands/
automod.rs1use 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#[derive(PartialEq)]
28enum DidMutate {
29 No,
30 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 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 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 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 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 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 if is_lib_dir {
221 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 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}
267fn 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 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 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 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 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}