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#[derive(PartialEq)]
29enum DidMutate {
30 No,
31 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 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 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 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 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 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 if is_lib_dir {
222 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 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}
268fn 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 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 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 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 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}