diff --git a/Cargo.lock b/Cargo.lock index 276c918..d453af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,6 +965,17 @@ dependencies = [ "libc", ] +[[package]] +name = "kdl" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "114071e31456ec827056ca691d141f8e96327d9d9a29140da2e6fba9a5f17b83" +dependencies = [ + "nom 7.1.0", + "phf", + "thiserror", +] + [[package]] name = "kqueue" version = "1.0.4" @@ -1418,7 +1429,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ + "phf_macros", "phf_shared", + "proc-macro-hack", ] [[package]] @@ -1441,6 +1454,20 @@ dependencies = [ "rand 0.7.3", ] +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "phf_shared" version = "0.8.0" @@ -1553,6 +1580,12 @@ dependencies = [ "toml", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + [[package]] name = "proc-macro2" version = "1.0.36" @@ -1833,18 +1866,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.133" +version = "1.0.134" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a" +checksum = "96b3c34c1690edf8174f5b289a336ab03f568a4460d8c6df75f2f3a692b3bc6a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.133" +version = "1.0.134" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537" +checksum = "784ed1fbfa13fe191077537b0d70ec8ad1e903cfe04831da608aa36457cb653d" dependencies = [ "proc-macro2", "quote", @@ -2583,6 +2616,7 @@ dependencies = [ "embed-resource", "futures", "insta", + "kdl", "miette", "mimalloc", "notify-rust", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 5abcbc7..35681b2 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -24,6 +24,7 @@ path = "src/main.rs" console-subscriber = { version = "0.1.0", optional = true } dunce = "1.0.2" futures = "0.3.17" +kdl = "3.0.0" miette = { version = "3.2.0", features = ["fancy"] } notify-rust = "4.5.2" tracing = "0.1.26" diff --git a/cli/src/config.rs b/cli/src/config.rs index 200a573..727c420 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -1,5 +1,7 @@ +pub mod file; mod init; mod runtime; +pub use file::Config as File; pub use init::init; pub use runtime::runtime; diff --git a/cli/src/config/file.rs b/cli/src/config/file.rs new file mode 100644 index 0000000..3fcce8d --- /dev/null +++ b/cli/src/config/file.rs @@ -0,0 +1,284 @@ +use kdl::{parse_document, KdlNode, KdlValue}; +use miette::{IntoDiagnostic, Report, Result}; +use watchexec::command::Shell; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Config { + pub commands: Vec, +} + +impl Config { + pub fn parse(input: &str) -> Result { + let kdl = parse_document(input).into_diagnostic()?; + let mut config = Config::default(); + + for root in kdl { + match root.name.as_str() { + "command" => config.commands.push(Command::parse(root)?), + otherwise => todo!("Root: {:?}", otherwise), + } + } + + Ok(config) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Command { + pub name: String, + pub run: Option, +} + +impl Command { + fn parse(node: KdlNode) -> Result { + let name = node + .values + .first() + .ok_or_else(|| Report::msg("Command has no name")) + .and_then(|name| match name { + KdlValue::String(s) => Ok(s.to_owned()), + otherwise => Err(Report::msg("Command name is not a string") + .wrap_err(format!("{:?}", otherwise))), + })?; + + let mut runs = node + .children + .iter() + .filter(|node| node.name == "run") + .map(Run::parse) + .collect::>>()?; + + if runs.len() > 1 { + return Err(Report::msg("Command has multiple runs")); + } + + Ok(Command { + name, + run: runs.pop(), + }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Run { + pub shell: Shell, + pub args: Vec, +} + +impl Run { + fn parse(node: &KdlNode) -> Result { + let args = node + .values + .iter() + .enumerate() + .map(|(n, v)| match v { + KdlValue::String(s) => Ok(s.to_owned()), + otherwise => Err(Report::msg(format!("Run argument {n} is not a string")) + .wrap_err(format!("{otherwise:?}"))), + }) + .collect::>>() + .and_then(|run| { + if run.is_empty() { + Err(Report::msg("Run has no arguments")) + } else { + Ok(run) + } + })?; + + let shell = node + .properties + .get("shell") + .map(|shell| match shell { + KdlValue::String(s) => Ok(s.to_owned()), + otherwise => { + Err(Report::msg("Run shell is not a string").wrap_err(format!("{otherwise:?}"))) + } + }) + .transpose()? + .map(|shell| match shell.as_str() { + "powershell" | "pwsh" => Shell::Powershell, + "none" => Shell::None, + #[cfg(windows)] + "cmd" => Shell::Cmd, + unix => Shell::Unix(unix.to_owned()), + }) + .unwrap_or_default(); + + if args.len() > 1 && shell != Shell::None { + Err(Report::msg("Run has more than one argument and a shell")) + } else { + Ok(Run { shell, args }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_command() { + let config = Config::parse(r#"command "empty""#).unwrap(); + assert_eq!(config.commands.len(), 1); + assert_eq!(config.commands[0].name, "empty"); + } + + #[test] + fn empty_command_with_braces() { + let config = Config::parse(r#"command "empty" {}"#).unwrap(); + assert_eq!(config.commands.len(), 1); + assert_eq!(config.commands[0].name, "empty"); + } + + #[test] + fn command_with_run_one() { + let config = Config::parse( + r#"command "running" { + run "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!(config.commands.len(), 1); + assert_eq!(config.commands[0].name, "running"); + assert_eq!( + config.commands[0].run, + Some(Run { + args: vec!["echo hello-world".to_owned()], + shell: Shell::default() + }) + ); + } + + #[test] + fn command_with_run_two() { + let config = Config::parse( + r#"command "running" { + run "echo" "hello-world" + }"#, + ) + .unwrap(); + assert_eq!(config.commands.len(), 1); + assert_eq!(config.commands[0].name, "running"); + assert_eq!( + config.commands[0].run, + Some(Run { + args: vec!["echo".to_owned(), "hello-world".to_owned()], + shell: Shell::default() + }) + ); + } + + #[test] + fn command_with_no_run() { + let config = Config::parse(r#"command "running" {}"#).unwrap(); + assert_eq!(config.commands.len(), 1); + assert_eq!(config.commands[0].name, "running"); + assert_eq!(config.commands[0].run, None); + } + + #[test] + #[should_panic] + fn command_with_empty_run() { + Config::parse( + r#"command "running" { + run + }"#, + ) + .unwrap(); + } + + #[test] + fn run_with_default_shell() { + let config = Config::parse( + r#"command "running" { + run "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!( + config.commands[0].run.as_ref().unwrap().shell, + Shell::default() + ); + assert_eq!( + config.commands[0].run.as_ref().unwrap().shell, + Shell::None + ); + } + + #[test] + fn run_with_explicit_shell() { + let config = Config::parse( + r#"command "running" { + run shell="bash" "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!( + config.commands[0].run.as_ref().unwrap().shell, + Shell::Unix("bash".to_owned()) + ); + } + + #[test] + fn run_with_powershell() { + let config = Config::parse( + r#"command "running" { + run shell="powershell" "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!( + config.commands[0].run.as_ref().unwrap().shell, + Shell::Powershell + ); + + let config = Config::parse( + r#"command "running" { + run shell="pwsh" "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!( + config.commands[0].run.as_ref().unwrap().shell, + Shell::Powershell + ); + } + + #[cfg(unix)] + #[test] + fn run_with_cmd_unix() { + let config = Config::parse( + r#"command "running" { + run shell="cmd" "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!( + config.commands[0].run.as_ref().unwrap().shell, + Shell::Unix("cmd".to_owned()) + ); + } + + #[cfg(windows)] + #[test] + fn run_with_cmd_windows() { + let config = Config::parse( + r#"command "running" { + run shell="cmd" "echo hello-world" + }"#, + ) + .unwrap(); + assert_eq!(config.commands[0].run.as_ref().unwrap().shell, Shell::Cmd); + } + + #[test] + #[should_panic] + fn multi_arg_run_with_shell() { + Config::parse( + r#"command "running" { + run shell="bash" "echo" "hello-world" + }"#, + ) + .unwrap(); + } +}