diff --git a/Cargo.lock b/Cargo.lock index 3b96e4e..b1a3de1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,7 +199,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jake" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "clap", @@ -207,6 +207,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "shlex", "toml", ] @@ -403,6 +404,12 @@ dependencies = [ "syn", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index a5c3bd1..7e1266b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jake" -version = "0.8.0" +version = "0.9.0" edition = "2024" license-file = "LICENSE" readme = "README.md" @@ -15,6 +15,7 @@ dotenv = "0.15.0" serde = "1.0.228" serde_json = "1.0.149" toml = { version = "1.0.1", features = ["preserve_order"] } +shlex = "2.0.1" [dev-dependencies] serial_test = "3.4.0" diff --git a/README.md b/README.md index 8dee1c3..8a243f1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It is based on TOML tasks definition (stored in a file called `jakefile.toml`) a - You can list tasks, by passing the `--list` flag - You can load `.env` file (in the same working directory or anywhere up in the directory tree), by passing the `--env` flag - Execute scripts from a `package.json` file with the `--js` flag. +- Execute inline scripts in Python, JavaScript, TypeScript, Ruby, PHP, Lua, and Perl - Dry-run mode: `jake --dry-run ` prints commands without executing them. ## Installation @@ -168,6 +169,38 @@ Output: hello ``` +### Inline Scripts + +You can also write inline scripts directly in your `jakefile.toml` using the `script` key instead of `command`. The first line of the script must specify the language: + +```toml +[python] +script = """py +def sum_ints(x: int, y: int) -> int: + return x+y + +print(sum_ints(1,2)) +""" + +[javascript] +script = """javascript +function main() { + console.log('hello world') +} + +main() +""" + +[typescript] +script = """ts +console.log('hey there, great to run TS!') +""" +``` + +Supported language identifiers: `py`/`python`, `js`/`javascript`, `ts`/`typescript`, `rb`/`ruby`, `php`, `lua`, `perl`. + +### package.json Scripts + If you are in a JS/TS application using a `package.json` for its scripts, as in this example: ```json diff --git a/package.json b/package.json index 2a24523..1af01a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cle-does-things/jake", - "version": "0.8.0", + "version": "0.9.0", "description": "A Make-like task executor for Unix operating systems", "main": "start.js", "directories": { diff --git a/pre-install.js b/pre-install.js index 93cf10a..12372e4 100644 --- a/pre-install.js +++ b/pre-install.js @@ -31,8 +31,8 @@ const features = process.env.npm_config_features ? `--features ${process.env.npm_config_features.replace(",", " ")}` : ""; -console.log(`Installing and compiling jake 0.8.0 ${features} ...`); -exec(`cargo install jake --vers 0.8.0 ${features}`, (error, stdout, stderr) => { +console.log(`Installing and compiling jake 0.9.0 ${features} ...`); +exec(`cargo install jake --vers 0.9.0 ${features}`, (error, stdout, stderr) => { console.log(stdout); if (error || stderr) { console.log(error || stderr); diff --git a/reference/contents/features.md b/reference/contents/features.md index 0352f93..dd3cc34 100644 --- a/reference/contents/features.md +++ b/reference/contents/features.md @@ -23,6 +23,8 @@ additional options from the command line. - **Loading .env files**: `.env` files can be loaded for task execution with the `--env` flag - **Executing package.json scripts**: in a JS/TS environment, scripts contained in a `package.json` file can be executed by passing the `--js` flag +- **Inline scripts**: write scripts in Python, JavaScript, TypeScript, Ruby, PHP, Lua, and Perl + directly in `jakefile.toml` using the `script` key - **Dry-run**: `--dry-run` prints the commands that would be run without executing them ### Comparison @@ -42,8 +44,9 @@ The table below compares `jake` against [`just`](https://github.com/casey/just) | No special spacing rules | ✅ | ✅ | ❌ | | Read .env | ✅ | ✅ | ❌ | | List available commands | ✅ | ✅ | ❌ | -| Recipes written in arbitrary languages | ❌ | ✅ | ❌ | +| Recipes written in arbitrary languages | ✅ | ✅ | ❌ | | Initialize a boilerplate jake/just/makefile | ✅ | ❌ | ❌ | +| Execute inline scripts in `jakefile.toml` | ✅ | ❌ | ❌ | | Execute scripts in `package.json` | ✅ | ❌ | ❌ | | Dry-run (print commands only) | ✅ | ✅ | ✅ | diff --git a/reference/contents/reference.md b/reference/contents/reference.md index e23f231..9fc14b1 100644 --- a/reference/contents/reference.md +++ b/reference/contents/reference.md @@ -63,7 +63,49 @@ Task name Command to execute Array of tasks to be executed before the task itself ``` -`command` is required when using the object syntax. `depends_on` is optional: if omitted, the task runs with no prerequisites. +`command` is required when using the object syntax, unless you use `script` instead (see below). `depends_on` is optional: if omitted, the task runs with no prerequisites. + +### Inline Scripts + +Instead of `command`, you can use `script` to write code directly in `jakefile.toml`. The first line of the script must be the language identifier, followed by the code: + +```toml +[python] +script = """py +def sum_ints(x: int, y: int) -> int: + return x + y + +print(sum_ints(1, 2)) +""" + +[javascript] +script = """javascript +function main() { + console.log('hello world') +} + +main() +""" + +[typescript] +script = """ts +console.log('hey there, great to run TS!') +""" +``` + +Supported language identifiers: + +| Language | Identifiers | +| ------------ | ----------------- | +| Python | `py`, `python` | +| JavaScript | `js`, `javascript`| +| TypeScript | `ts`, `typescript`| +| Ruby | `rb`, `ruby` | +| PHP | `php` | +| Lua | `lua` | +| Perl | `perl` | + +Scripts are executed by invoking the appropriate interpreter (e.g. `python3 -c`, `node -e`, `npx tsx -e`). ### The Default Task diff --git a/src/code_blocks.rs b/src/code_blocks.rs new file mode 100644 index 0000000..bdafd28 --- /dev/null +++ b/src/code_blocks.rs @@ -0,0 +1,59 @@ +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, Copy)] +pub enum ScriptLanguage { + Javascript, + Typescript, + Python, + Ruby, + Php, + Lua, + Perl, +} + +#[derive(Debug)] +pub struct LanguageNotSupportedError { + message: String, +} + +impl std::error::Error for LanguageNotSupportedError {} + +impl Display for LanguageNotSupportedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl FromStr for ScriptLanguage { + type Err = LanguageNotSupportedError; + + fn from_str(s: &str) -> Result { + match s { + "py" | "python" => Ok(Self::Python), + "ts" | "typescript" => Ok(Self::Typescript), + "js" | "javascript" => Ok(Self::Javascript), + "rb" | "ruby" => Ok(Self::Ruby), + "lua" => Ok(Self::Lua), + "perl" => Ok(Self::Perl), + "php" => Ok(Self::Php), + _ => Err(LanguageNotSupportedError { + message: format!("{} is not a supported language for jake scripts", s), + }), + } + } +} + +impl ScriptLanguage { + pub fn command(&self, script: &str) -> anyhow::Result { + let quoted_script = shlex::try_quote(script)?; + match self { + Self::Javascript => Ok(format!("node -e {}", quoted_script)), + Self::Lua => Ok(format!("lua -e {}", quoted_script)), + Self::Python => Ok(format!("python3 -c {}", quoted_script)), + Self::Perl => Ok(format!("perl -e {}", quoted_script)), + Self::Php => Ok(format!("php -r {}", quoted_script)), + Self::Ruby => Ok(format!("ruby -e {}", quoted_script)), + Self::Typescript => Ok(format!("npx tsx -e {}", quoted_script)), + } + } +} diff --git a/src/load.rs b/src/load.rs index c941326..d8b69dc 100644 --- a/src/load.rs +++ b/src/load.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::env; use std::path::Path; +use std::str::FromStr; +use crate::code_blocks::ScriptLanguage; use crate::models::{Executor, NodeState, TaskNode}; use anyhow::{Result, anyhow}; use toml::map::Map; @@ -59,7 +61,7 @@ pub fn list_jakefile_tasks(file_path: Option<&str>) -> Result> { let mut commands: Vec = vec![]; for key in parsed.keys() { - commands.push(key.clone().to_owned()); + commands.push(key.to_owned()); } Ok(commands) @@ -73,9 +75,9 @@ fn task_to_task_node(available_tasks: &Map, task: &str) -> Result )); } let task_node = if let Some(task_table) = available_tasks[task].as_table() { - if !task_table.contains_key("command") { + if !task_table.contains_key("command") && !task_table.contains_key("script") { return Err(anyhow!( - "`command` key not available for the requested task: ensure that there are no typos and the TOML syntax is correct before running again" + "`command` or `script` key not available for the requested task: ensure that there are no typos and the TOML syntax is correct before running again" )); } let mut dependencies: Vec = vec![]; @@ -89,15 +91,45 @@ fn task_to_task_node(available_tasks: &Map, task: &str) -> Result } } } - let command = match task_table["command"].as_str() { - Some(c) => c, - None => return Err(anyhow!("Unsupported value for the task's command")), + let mut is_script = false; + let key = if task_table.contains_key("command") { + "command" + } else { + is_script = true; + "script" }; - TaskNode::new(command.to_string(), dependencies) + let command = match task_table[key].as_str() { + Some(c) => { + if is_script { + let lang_script = c.split_once("\n"); + if let Some((lang, script)) = lang_script + && !lang.trim().is_empty() + && !script.trim().is_empty() + { + let script_language = ScriptLanguage::from_str(lang)?; + script_language.command(script)? + } else { + return Err(anyhow!("Script could not be parsed correctly")); + } + } else { + c.to_string() + } + } + None => { + return Err(anyhow!( + "Unsupported value for the task's command or script" + )); + } + }; + TaskNode::new(command, dependencies) } else { let command = match available_tasks[task].as_str() { Some(t) => t, - None => return Err(anyhow!("Unsupported value for the task's command")), + None => { + return Err(anyhow!( + "Unsupported value for the task's command or script" + )); + } }; let dependencies: Vec = vec![]; TaskNode::new(command.to_string(), dependencies) @@ -168,28 +200,15 @@ pub fn execute_command( let mut state_map: HashMap = HashMap::new(); resolve_dependencies(&available_tasks, task, &mut execution_order, &mut state_map)?; for command in &execution_order[..execution_order.len() - 1] { - let command_slice: Vec<&str> = command.split_whitespace().collect(); - let command_args = if command_slice.len() > 1 { - &command_slice[1..] - } else { - &[] - }; - executor.execute(command_slice[0], command_args.to_vec(), load_env)?; + executor.execute(command.as_str(), load_env)?; } // the actual command to execute is the last one in the execution order let cmd = &execution_order[execution_order.len() - 1]; - let cmd_parts: Vec<&str> = cmd.split_whitespace().collect(); - let main_command = cmd_parts[0]; - if cmd_parts.len() == 1 && cmd_options.is_empty() { - executor.execute(main_command, vec![], load_env)?; - } else if cmd_parts.len() == 1 && !cmd_options.is_empty() { - executor.execute(main_command, cmd_options, load_env)?; - } else if cmd_parts.len() > 1 && cmd_options.is_empty() { - let cmd_slice = &cmd_parts[1..]; - executor.execute(main_command, cmd_slice.to_vec(), load_env)?; + if cmd_options.is_empty() { + executor.execute(cmd, load_env)?; } else { - let cmd_slice = [&cmd_parts[1..], &cmd_options[..]].concat(); - executor.execute(main_command, cmd_slice, load_env)?; + let full_command = cmd.to_owned() + " " + cmd_options.join(" ").as_str(); + executor.execute(&full_command, load_env)?; } Ok(()) } @@ -232,13 +251,7 @@ mod tests { } impl Executor for MockCommandExecutor { - fn execute( - &self, - main_command: &str, - args: Vec<&str>, - _load_env: bool, - ) -> anyhow::Result<()> { - let full_command = main_command.to_owned() + " " + &args.join(" "); + fn execute(&self, full_command: &str, _load_env: bool) -> anyhow::Result<()> { std::fs::write("test.mock", full_command)?; Ok(()) } @@ -347,6 +360,69 @@ mod tests { assert_eq!(mock_content_4.trim(), "echo ciao"); } + #[test] + #[serial] + fn test_mock_script_execution() { + let mock_executor = MockCommandExecutor::new(); + let result = execute_command( + Some("testfiles/scripts.toml"), + "python", + "", + &mock_executor, + false, + ); + assert!(result.is_ok()); + let mock_content = + std::fs::read_to_string("test.mock").expect("Should be able to read test.mock"); + assert!(mock_content.contains("python3 -c")); + let result_1 = execute_command( + Some("testfiles/scripts.toml"), + "javascript", + "", + &mock_executor, + false, + ); + assert!(result_1.is_ok()); + let mock_content_1 = + std::fs::read_to_string("test.mock").expect("Should be able to read test.mock"); + assert!(mock_content_1.contains("node -e")); + let result_2 = execute_command( + Some("testfiles/scripts.toml"), + "typescript", + "", + &mock_executor, + false, + ); + assert!(result_2.is_ok()); + let mock_content_2 = + std::fs::read_to_string("test.mock").expect("Should be able to read test.mock"); + assert!(mock_content_2.contains("npx tsx -e")); + } + + #[test] + #[serial] + fn test_script_execution_error() { + let executor = CommandExecutor::new(); + let result = execute_command( + Some("testfiles/scripts.toml"), + "no-tag", + "", + &executor, + false, + ); + assert!(result.is_err_and(|e| e.to_string() == "Script could not be parsed correctly".to_string())) + } + + #[test] + #[serial] + fn test_script_execution_unsupported_lang() { + let executor = CommandExecutor::new(); + let result = execute_command(Some("testfiles/scripts.toml"), "rust", "", &executor, false); + assert!(result.is_err_and( + |e| e.to_string() == "rs is not a supported language for jake scripts".to_string() + )) + } + #[test] #[serial] fn test_command_execution() { @@ -394,9 +470,8 @@ mod tests { false, ); assert_eq!( - result.is_err_and( - |e| e.to_string() == "Unsupported value for the task's command".to_string() - ), + result.is_err_and(|e| e.to_string() + == "Unsupported value for the task's command or script".to_string()), true ); } @@ -413,7 +488,7 @@ mod tests { false, ); assert_eq!(result.is_err_and( - |e| e.to_string() == "`command` key not available for the requested task: ensure that there are no typos and the TOML syntax is correct before running again".to_string() + |e| e.to_string() == "`command` or `script` key not available for the requested task: ensure that there are no typos and the TOML syntax is correct before running again".to_string() ), true); } @@ -429,9 +504,8 @@ mod tests { false, ); assert_eq!( - result.is_err_and( - |e| e.to_string() == "Unsupported value for the task's command".to_string() - ), + result.is_err_and(|e| e.to_string() + == "Unsupported value for the task's command or script".to_string()), true ); } @@ -530,9 +604,8 @@ mod tests { false, ); assert_eq!( - result.is_err_and( - |e| e.to_string() == "Unsupported value for the task's command".to_string() - ), + result.is_err_and(|e| e.to_string() + == "Unsupported value for the task's command or script".to_string()), true ); } @@ -550,7 +623,7 @@ mod tests { ); assert_eq!( result.is_err_and( - |e| e.to_string() == "`command` key not available for the requested task: ensure that there are no typos and the TOML syntax is correct before running again".to_string() + |e| e.to_string() == "`command` or `script` key not available for the requested task: ensure that there are no typos and the TOML syntax is correct before running again".to_string() ), true ); diff --git a/src/main.rs b/src/main.rs index bc33cf1..e67450b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use crate::{ use anyhow::anyhow; use clap::Parser; +mod code_blocks; mod env_vars; mod initialize; mod load; @@ -15,7 +16,7 @@ mod package_json; /// Make-like task executor for Unix-based operating systems #[derive(Parser, Debug)] -#[command(version = "0.8.0")] +#[command(version = "0.9.0")] #[command(name = "jake")] #[command(about, long_about = None)] struct Args { diff --git a/src/models.rs b/src/models.rs index eac97e1..dff2415 100644 --- a/src/models.rs +++ b/src/models.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use crate::env_vars::dotenv_to_hashmap; pub trait Executor { - fn execute(&self, main_command: &str, args: Vec<&str>, load_env: bool) -> anyhow::Result<()>; + fn execute(&self, full_command: &str, load_env: bool) -> anyhow::Result<()>; } pub struct CommandExecutor; @@ -24,21 +24,14 @@ impl DryRunExecutor { } impl Executor for DryRunExecutor { - fn execute(&self, main_command: &str, args: Vec<&str>, _load_env: bool) -> anyhow::Result<()> { - let full_command = std::iter::once(main_command) - .chain(args) - .collect::>() - .join(" "); + fn execute(&self, full_command: &str, _load_env: bool) -> anyhow::Result<()> { println!("{}", full_command); Ok(()) } } impl Executor for CommandExecutor { - fn execute(&self, main_command: &str, args: Vec<&str>, load_env: bool) -> anyhow::Result<()> { - let mut command_args = args; - command_args.insert(0, main_command); - let full_command = command_args.join(" "); + fn execute(&self, full_command: &str, load_env: bool) -> anyhow::Result<()> { let mut cmd = if !load_env { std::process::Command::new("sh") .arg("-c") diff --git a/src/package_json.rs b/src/package_json.rs index 5d77703..39ebe37 100644 --- a/src/package_json.rs +++ b/src/package_json.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, env, fs, path::Path}; +use std::{ + collections::{HashMap, HashSet}, + env, fs, + path::Path, +}; use anyhow::{Result, anyhow}; use serde_json::Value; @@ -87,7 +91,7 @@ fn split_on_operators(cmd: &str) -> Vec { parts } -fn extract_executables(cmd: &str) -> Vec { +fn extract_executables(cmd: &str) -> HashSet { split_on_operators(cmd) .iter() .filter_map(|part| part.split_whitespace().next().map(String::from)) @@ -114,7 +118,7 @@ fn resolve_command_executables(command: &str) -> String { let mut full_command = command.to_string(); for e in execs { if !e.starts_with("/") && !e.starts_with(".") && !executable_exists(&e) { - full_command = full_command.replacen(&e, &format!("./node_modules/.bin/{}", e), 1); + full_command = full_command.replace(&e, &format!("./node_modules/.bin/{}", e)); } } full_command @@ -143,7 +147,7 @@ pub fn execute_script( let command = get_script_command(scripts, script_name)?; let resolved_command = resolve_command_executables(&command); println!("\x1b[38;5;247m{}\x1b[0m\n", command); - executor.execute(&resolved_command, vec![], load_env)?; + executor.execute(&resolved_command, load_env)?; Ok(()) } @@ -164,13 +168,7 @@ mod tests { } impl Executor for MockCommandExecutor { - fn execute( - &self, - main_command: &str, - args: Vec<&str>, - _load_env: bool, - ) -> anyhow::Result<()> { - let full_command = main_command.to_owned() + " " + &args.join(" "); + fn execute(&self, full_command: &str, _load_env: bool) -> anyhow::Result<()> { std::fs::write("package.mock", full_command)?; Ok(()) } @@ -336,7 +334,10 @@ mod tests { fn test_extract_executables() { let cmd = "cat file.txt && cat file.rs | grep 'something'"; let execs = extract_executables(cmd); - assert_eq!(vec!["cat", "cat", "grep"], execs); + assert_eq!( + HashSet::from(["cat".to_string(), "cat".to_string(), "grep".to_string()]), + execs + ); } #[test] @@ -346,6 +347,16 @@ mod tests { assert_eq!(resolved, "echo 'linting' && ./node_modules/.bin/eslint ."); } + #[test] + fn test_resolve_command_executables_dedups() { + let cmd = "eslint src/ && eslint __tests__/ && echo 'done'"; + let resolved = resolve_command_executables(cmd); + assert_eq!( + resolved, + "./node_modules/.bin/eslint src/ && ./node_modules/.bin/eslint __tests__/ && echo 'done'" + ); + } + #[test] fn test_resolve_command_executables_with_local_exec() { let cmd = "eslint . && ./post-lint"; diff --git a/testfiles/scripts.toml b/testfiles/scripts.toml new file mode 100644 index 0000000..ffe571d --- /dev/null +++ b/testfiles/scripts.toml @@ -0,0 +1,34 @@ +[python] +script = """py +def sum_ints(x: int, y: int) -> int: + return x+y + +print(sum_ints(1,2)) +""" + +[javascript] +script = """javascript +function main() { + console.log('hello world') +} + +main() +""" + +[typescript] +script = """ts +console.log('hey there, great to run TS!') +console.log('it works!!!') +""" + +[no-tag] +script = """ +print('something') +""" + +[rust] +script = """rs +fn main() { + println('Hello world!'); +} +"""