Repository: seaofvoices/darklua (rust)

Issue:
Watch mode
Add an argument to the process command to watch for file changes and automatically process the files again.

This crate seems like the optimal choice for implementation https://docs.rs/notify/latest/notify/

Pull request description:
Added watch subcommand
This adds a simple watch command that automatically runs the process command whenever a file change / creation is found. It can be optimised by getting the `process_options` within the watch subcommand and passing it into the process command but I did not do this. Let me know if you would like that implemented.

Closes #98 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0149740..f47d077 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+* add watch argument (`--watch` or `-w`) to process command to automatically re-run darklua on file changes ([#123](https://github.com/seaofvoices/darklua/pull/123))
+
 ## 0.10.2
 
 * fix crashes when bundling code and fix `remove_spaces` and `remove_comments` rule for missing cases (if expressions and numeric for) ([#119](https://github.com/seaofvoices/darklua/pull/119))
diff --git a/Cargo.lock b/Cargo.lock
index da0c3c6..f39b655 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -105,6 +105,12 @@ version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
+[[package]]
+name = "bitflags"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
+
 [[package]]
 name = "block-buffer"
 version = "0.10.4"
@@ -392,6 +398,16 @@ dependencies = [
  "typenum",
 ]
 
+[[package]]
+name = "ctrlc"
+version = "3.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82e95fbd621905b854affdc67943b043a0fbb6ed7385fd5a25650d19a8a6cfdf"
+dependencies = [
+ "nix",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "darklua"
 version = "0.10.2"
@@ -399,6 +415,7 @@ dependencies = [
  "assert_cmd",
  "clap",
  "criterion",
+ "ctrlc",
  "durationfmt",
  "elsa",
  "env_logger",
@@ -408,6 +425,8 @@ dependencies = [
  "json5",
  "log",
  "node-sys",
+ "notify",
+ "notify-debouncer-mini",
  "paste",
  "pathdiff",
  "pretty_assertions",
@@ -543,12 +562,33 @@ dependencies = [
  "instant",
 ]
 
+[[package]]
+name = "filetime"
+version = "0.2.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0"
+dependencies = [
+ "cfg-if 1.0.0",
+ "libc",
+ "redox_syscall",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "fnv"
 version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "full_moon"
 version = "0.18.1"
@@ -702,6 +742,26 @@ dependencies = [
  "hashbrown 0.14.0",
 ]
 
+[[package]]
+name = "inotify"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "insta"
 version = "1.30.0"
@@ -790,6 +850,26 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "kqueue"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -798,9 +878,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.146"
+version = "0.2.148"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
+checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
 
 [[package]]
 name = "libm"
@@ -892,6 +972,29 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
 
+[[package]]
+name = "mio"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "nix"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
+dependencies = [
+ "bitflags 2.4.0",
+ "cfg-if 1.0.0",
+ "libc",
+]
+
 [[package]]
 name = "node-sys"
 version = "0.4.2"
@@ -928,6 +1031,36 @@ dependencies = [
  "nom",
 ]
 
+[[package]]
+name = "notify"
+version = "6.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
+dependencies = [
+ "bitflags 2.4.0",
+ "crossbeam-channel",
+ "filetime",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "walkdir",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "notify-debouncer-mini"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43"
+dependencies = [
+ "crossbeam-channel",
+ "log",
+ "notify",
+]
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.46.0"
@@ -1214,7 +1347,7 @@ version = "0.3.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
 dependencies = [
- "bitflags",
+ "bitflags 1.3.2",
 ]
 
 [[package]]
@@ -1264,7 +1397,7 @@ version = "0.37.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
 dependencies = [
- "bitflags",
+ "bitflags 1.3.2",
  "errno",
  "io-lifetimes",
  "libc",
diff --git a/Cargo.toml b/Cargo.toml
index 1572257..503197f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,14 +23,16 @@ name = "darklua"
 path = "src/bin.rs"
 
 [features]
+default = ["stacker"]
 tracing = ["dep:tracing"]
+stacker = ["full_moon/stacker"]
 
 [dependencies]
 clap = { version = "4.3.11", features = ["derive"] }
 durationfmt = "0.1.1"
 elsa = "1.8.1"
 env_logger = "0.10.0"
-full_moon = { version = "0.18.1", features = ["roblox", "stacker"] }
+full_moon = { version = "0.18.1", features = ["roblox"] }
 json5 = "0.4.1"
 log = "0.4.19"
 pathdiff = "0.2.1"
@@ -59,6 +61,11 @@ tracing-tracy = "0.10.1"
 node-sys = "0.4.2"
 web-sys = { version = "0.3.60", features = ["Window", "Performance"] }
 
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+ctrlc = { version = "3.4.1", features = ["termination"] }
+notify = "6.1.1"
+notify-debouncer-mini = "0.4.1"
+
 # This is needed because when runnin `cargo test`, the library and its
 # dependencies are build with the `dev` profile. To make sure full_moon
 # does not stack overflow when parsing complex code, it needs to be compiled
diff --git a/site/darklua-wasm/Cargo.toml b/site/darklua-wasm/Cargo.toml
index 606ed17..f519b56 100644
--- a/site/darklua-wasm/Cargo.toml
+++ b/site/darklua-wasm/Cargo.toml
@@ -24,7 +24,7 @@ wasm-bindgen = "0.2.63"
 console_error_panic_hook = { version = "0.1.6", optional = true }
 
 js-sys = "0.3.55"
-darklua = {path = "../.."}
+darklua = { path = "../..", default-features = false }
 serde = { version = "1.0", features = ["derive"] }
 json5 = "0.2"
 
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index de8331c..93b0d33 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -10,7 +10,7 @@ use self::error::CliError;
 
 type CommandResult = Result<(), CliError>;
 
-#[derive(Debug, Args)]
+#[derive(Debug, Args, Clone)]
 pub struct GlobalOptions {
     /// Sets verbosity level (can be specified multiple times)
     #[arg(long, short, global(true), action = clap::ArgAction::Count)]
diff --git a/src/cli/process.rs b/src/cli/process.rs
index d498b01..c36aa22 100644
--- a/src/cli/process.rs
+++ b/src/cli/process.rs
@@ -4,11 +4,18 @@ use crate::cli::{CommandResult, GlobalOptions};
 
 use clap::Args;
 use darklua_core::{GeneratorParameters, Resources};
-use std::path::PathBuf;
+#[cfg(not(target_arch = "wasm32"))]
+use notify::RecursiveMode;
+#[cfg(not(target_arch = "wasm32"))]
+use notify_debouncer_mini::{new_debouncer, DebounceEventResult, DebouncedEventKind};
+use std::path::{Path, PathBuf};
 use std::str::FromStr;
-use std::time::Instant;
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
 
-#[derive(Debug, Args)]
+const FILE_WATCHING_DEBOUNCE_DURATION_MILLIS: u64 = 400;
+
+#[derive(Debug, Args, Clone)]
 pub struct Options {
     /// Path to the lua file to process.
     input_path: PathBuf,
@@ -21,6 +28,9 @@ pub struct Options {
     /// This will override the format given by the configuration file.
     #[arg(long)]
     format: Option<LuaFormat>,
+    /// Watch files and directories for changes and automatically re-run
+    #[arg(long, short)]
+    watch: bool,
 }
 
 #[derive(Debug, Copy, Clone)]
@@ -47,25 +57,7 @@ impl FromStr for LuaFormat {
     }
 }
 
-pub fn run(options: &Options, _global: &GlobalOptions) -> CommandResult {
-    log::debug!("running `process`: {:?}", options);
-
-    let resources = Resources::from_file_system();
-    let mut process_options =
-        darklua_core::Options::new(&options.input_path).with_output(&options.output_path);
-
-    if let Some(config) = options.config.as_ref() {
-        process_options = process_options.with_configuration_at(config);
-    }
-
-    if let Some(format) = options.format.as_ref() {
-        process_options = process_options.with_generator_override(match format {
-            LuaFormat::Dense => GeneratorParameters::default_dense(),
-            LuaFormat::Readable => GeneratorParameters::default_readable(),
-            LuaFormat::RetainLines => GeneratorParameters::RetainLines,
-        })
-    }
-
+fn process(resources: Resources, process_options: darklua_core::Options) -> Result<(), CliError> {
     let process_start_time = Instant::now();
 
     let result = darklua_core::process(&resources, process_options);
@@ -112,3 +104,134 @@ pub fn run(options: &Options, _global: &GlobalOptions) -> CommandResult {
         }
     }
 }
+
+impl Options {
+    fn process(&self) -> Result<(), CliError> {
+        let resources = Resources::from_file_system();
+
+        let mut process_options =
+            darklua_core::Options::new(&self.input_path).with_output(&self.output_path);
+
+        if let Some(config) = self.config.as_ref() {
+            process_options = process_options.with_configuration_at(config);
+        }
+
+        if let Some(format) = self.format {
+            process_options = process_options.with_generator_override(match format {
+                LuaFormat::Dense => GeneratorParameters::default_dense(),
+                LuaFormat::Readable => GeneratorParameters::default_readable(),
+                LuaFormat::RetainLines => GeneratorParameters::RetainLines,
+            })
+        }
+
+        process(resources, process_options)
+    }
+}
+
+const DEFAULT_CONFIG_PATHS: [&str; 2] = [".darklua.json", ".darklua.json5"];
+
+pub fn run(options: &Options, _global: &GlobalOptions) -> CommandResult {
+    log::debug!("running `process`: {:?}", options);
+
+    if cfg!(not(target_arch = "wasm32")) && options.watch {
+        // run process once initially
+        if let Err(_cli_err) = options.process() {
+            // ignore error darklua need to setup watch mode
+        }
+
+        let watcher_options = options.clone();
+
+        let mut debouncer = new_debouncer(
+            Duration::from_millis(FILE_WATCHING_DEBOUNCE_DURATION_MILLIS),
+            move |events: DebounceEventResult| {
+                match events {
+                    Ok(events) => {
+                        if events
+                            .iter()
+                            .any(|event| matches!(event.kind, DebouncedEventKind::Any))
+                        {
+                            log::debug!("changes detected, re-running process");
+                            if let Err(_cli_err) = watcher_options.process() {
+                                // ignore error since it already has been printed
+                            }
+                        }
+                    }
+                    Err(err) => {
+                        log::error!(
+                            "an error occured while watching file system for changes: {}",
+                            err
+                        );
+                    }
+                }
+            },
+        )
+        .map_err(|err| {
+            log::error!("unable to create file watcher: {}", err);
+            CliError::new(1)
+        })?;
+
+        log::debug!(
+            "start watching file system {}",
+            options.input_path.display()
+        );
+
+        debouncer
+            .watcher()
+            .watch(&options.input_path, RecursiveMode::Recursive)
+            .map_err(|err| {
+                log::error!(
+                    "unable to start watching file system at `{}`: {}",
+                    options.input_path.display(),
+                    err
+                );
+                CliError::new(1)
+            })?;
+
+        if let Some(config) = options.config.as_ref() {
+            log::debug!("start watching provided config path {}", config.display());
+            debouncer
+                .watcher()
+                .watch(config, RecursiveMode::NonRecursive)
+                .map_err(|err| {
+                    log::error!(
+                        "unable to start watching file system at `{}`: {}",
+                        config.display(),
+                        err
+                    );
+                    CliError::new(1)
+                })?;
+        } else {
+            for path in DEFAULT_CONFIG_PATHS.iter().map(Path::new) {
+                if path.exists() {
+                    log::debug!("start watching default config path {}", path.display());
+                    debouncer
+                        .watcher()
+                        .watch(path, RecursiveMode::NonRecursive)
+                        .map_err(|err| {
+                            log::error!(
+                                "unable to start watching file system at `{}`: {}",
+                                path.display(),
+                                err
+                            );
+                            CliError::new(1)
+                        })?;
+                }
+            }
+        }
+
+        let (sender, receiver) = mpsc::channel();
+
+        ctrlc::set_handler(move || sender.send(()).expect("unable to send signal to terminate"))
+            .map_err(|err| {
+                log::error!("unable to set Ctrl-C handler: {}", err);
+                CliError::new(1)
+            })?;
+
+        log::debug!("waiting for Ctrl-C to close the program");
+        receiver.recv().expect("Could not receive from channel.");
+
+        Ok(())
+    } else {
+        options.process()
+    }
+}
diff --git a/src/nodes/expressions/string.rs b/src/nodes/expressions/string.rs
index 2047c3c..cad9635 100644
--- a/src/nodes/expressions/string.rs
+++ b/src/nodes/expressions/string.rs
@@ -449,14 +449,14 @@ mod test {
 
     #[test]
     fn has_single_quote_is_true_if_unescaped_single_quotes_with_escaped_backslash() {
-        let string = StringExpression::from_value(r#"don\\'t"#);
+        let string = StringExpression::from_value(r"don\\'t");
 
         assert!(string.has_single_quote());
     }
 
     #[test]
     fn has_single_quote_is_false_if_escaped_single_quotes() {
-        let string = StringExpression::from_value(r#"don\'t"#);
+        let string = StringExpression::from_value(r"don\'t");
 
         assert!(!string.has_single_quote());
     }
diff --git a/src/rules/rename_variables/mod.rs b/src/rules/rename_variables/mod.rs
index 5bc1527..a5e1934 100644
--- a/src/rules/rename_variables/mod.rs
+++ b/src/rules/rename_variables/mod.rs
@@ -81,7 +81,7 @@ impl RenameVariables {
             result.push("$roblox".to_owned());
         }
 
-        result.extend(globals_set.into_iter());
+        result.extend(globals_set);
         result.sort();
         result
     }
diff --git a/src/rules/rename_variables/rename_processor.rs b/src/rules/rename_variables/rename_processor.rs
index e2ffecd..936bb94 100644
--- a/src/rules/rename_variables/rename_processor.rs
+++ b/src/rules/rename_variables/rename_processor.rs
@@ -5,6 +5,7 @@ use crate::process::{utils::KEYWORDS, NodeProcessor, Scope};
 use std::cmp::Ordering;
 use std::collections::{HashMap, HashSet};
 use std::iter::FromIterator;
+use std::mem;
 
 #[derive(Debug)]
 pub struct RenameProcessor {
@@ -66,7 +67,7 @@ impl RenameProcessor {
     }
 
     fn replace_identifier(&mut self, identifier: &mut String) {
-        let original = identifier.drain(..).collect();
+        let original = mem::take(identifier);
         let obfuscated_name = self.generate_identifier();
 
         identifier.push_str(&obfuscated_name);
@@ -159,7 +160,7 @@ mod test {
     use super::*;
 
     fn new_scope() -> RenameProcessor {
-        RenameProcessor::new(Vec::new().into_iter(), true)
+        RenameProcessor::new(Vec::new(), true)
     }
 
     #[test]


Test patch:
diff --git a/tests/snapshots/process_help_command.snap b/tests/snapshots/process_help_command.snap
index 5262818..91bc76c 100644
--- a/tests/snapshots/process_help_command.snap
+++ b/tests/snapshots/process_help_command.snap
@@ -25,6 +25,9 @@ Options:
       --format <FORMAT>
           Choose how Lua code is formatted ('dense', 'readable' or 'retain_lines'). This will override the format given by the configuration file
 
+  -w, --watch
+          Watch files and directories for changes and automatically re-run
+
   -h, --help
           Print help (see a summary with '-h')