├── .gitignore ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── example_crates ├── test-parametrization-with-files │ ├── Cargo.toml │ ├── src │ │ └── lib.rs │ └── testcases.json └── test-parametrization │ ├── Cargo.toml │ └── src │ └── lib.rs └── src └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "script-macro" 3 | authors = ["Markus Unterwaditzer "] 4 | description = "Write proc-macros inline with other source code" 5 | version = "0.1.2" 6 | edition = "2021" 7 | readme = "README.md" 8 | license = "MIT" 9 | repository = "https://github.com/untitaker/script-macro" 10 | exclude = ["example_crates/**"] 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [lib] 15 | proc-macro = true 16 | 17 | [features] 18 | default = [] 19 | parse-json = ["dep:serde_json", "rhai/serde"] 20 | parse-yaml = ["dep:serde_yaml", "rhai/serde"] 21 | filesystem = ["dep:rhai-fs"] 22 | 23 | [dependencies] 24 | glob = { version = "0.3.1", optional = true } 25 | rhai = { version = "1.13.0" } 26 | rhai-fs = { version = "0.1.2", optional = true } 27 | serde_json = { version = "1.0.96", optional = true } 28 | serde_yaml = { version = "0.9.21", optional = true } 29 | syn = "2.0.15" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Markus Unterwaditzer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | cargo test 3 | .PHONY: test 4 | 5 | test-all-examples: 6 | set -eux -o pipefail; \ 7 | for dir in ./example_crates/*/; do \ 8 | (cd $$dir && cargo test); \ 9 | done 10 | 11 | .PHONY: test-all-examples 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # script-macro 2 | 3 | An **experimental** way to write simple proc-macros inline with other source code. 4 | 5 | Did you ever end up getting frustrated at the boilerplate involved in writing 6 | proc macros, and wished you could just write a Python or Bash script to 7 | generate the code instead? 8 | 9 | ```rust 10 | pub fn add(left: usize, right: usize) -> usize { 11 | left + right 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use super::*; 17 | 18 | #[script_macro::run_script_on(r##" 19 | let output = item; 20 | 21 | for x in 0..10 { 22 | for y in 0..10 { 23 | output += ` 24 | #[test] 25 | fn it_works_${x}_${y}() { 26 | it_works(${x}, ${y}, ${x + y}); 27 | }`; 28 | } 29 | } 30 | 31 | return output; 32 | "##)] 33 | fn it_works(x: usize, y: usize, out: usize) { 34 | assert_eq!(add(x, y), out); 35 | } 36 | } 37 | ``` 38 | 39 | Macros are not Rust source code, instead they are written in the [RHAI](https://rhai.rs/) scripting language. This comes with advantages and disadvantages: 40 | 41 | * **Downside: No access to the Rust crate ecosystem** -- RHAI is its own entire 42 | separate language, and therefore you can't use Rust crates inside. RHAI _can_ 43 | be extended with custom Rust functions, but `script-macro` does not support 44 | that yet. For now, `script-macro` exposes a few helpers commonly useful in 45 | code generation. 46 | 47 | * **Upside: Sandboxability** -- Proc macros executed with `script-macro` cannot 48 | access the internet or perform arbitrary syscalls. Proc macros _are_ given 49 | full access to the filesystem via the functions available through 50 | [`rhai-fs`](https://docs.rs/rhai-fs/latest/rhai_fs/), but in a future version 51 | this could be configurable, for example read-only access or restricted to 52 | certain directories. 53 | 54 | * **Downside: Dependency on RHAI runtime** -- RHAI is an entire language 55 | runtime that has to be compiled _once_ before any of your proc macros run. 56 | 57 | * **Upside: No recompilation when editing proc macros.** -- Proc macros are 58 | interpreted scripts. When editing them, only the containing crate needs to be 59 | recompiled, not `script-macro` itself. This _could_ end up being faster when 60 | dealing with a lot of proc macros. 61 | 62 | See also [watt](https://github.com/dtolnay/watt), which appears to have 63 | similar tradeoffs about compilation speed (compile runtime for all macros 64 | once, run all macros without compilation) 65 | 66 | ## Seriously? 67 | 68 | I seriously do wish that proc_macros were easier to write (inline with other 69 | code) and didn't contribute as much to compile time. One area where this comes 70 | up for me particularly often is programmatic test generation (or, 71 | parametrization). 72 | 73 | This is my best shot at making this happen today, but that doesn't mean I'm 74 | convinced that the end result is viable for production use. I hope that it 75 | inspires somebody else to build something better. 76 | 77 | ## API 78 | 79 | There are two main macros to choose from: 80 | 81 | * `script_macro::run_script_on` -- Attribute macro that executes a given script 82 | with the annotated function/module's sourcecode available as a global string 83 | under `item`. 84 | 85 | The return value of the script is the source code that the item will be 86 | replaced with. 87 | 88 | Here is a simple script macro that adds `#[test]` to the annotated function. 89 | 90 | ```rust 91 | #[script_macro::run_script_on(r##" 92 | return "#[test]" + item; 93 | "##)] 94 | fn it_works(x: usize, y: usize, out: usize) { 95 | assert_eq!(add(x, y), out); 96 | } 97 | ``` 98 | 99 | * `script_macro::run_script` -- Function macro that executes the given script. There are no inputs. 100 | 101 | ```rust 102 | script_macro::run_script!(r##" 103 | return `fn main() { println!("hello world"); }`; 104 | "##); 105 | ``` 106 | 107 | ## Script API 108 | 109 | From within the script, the stdlib of RHAI is available. Additionally the 110 | following features can be enabled: 111 | 112 | ### `features = ["parse-json"]` 113 | 114 | Adds `serde-json` crate and defines the following additional function: 115 | 116 | * `parse_json(String) -> Dynamic` -- Takes JSON payload as string and returns 117 | the parsed payload as unstructured data (such as, RHAI object map or array). 118 | 119 | * `stringify_json(Dynamic) -> String` -- Convert a RHAI object to a YAML 120 | string, inverse of `parse_json`. 121 | 122 | ### `features = ["parse-yaml"]` 123 | 124 | Adds `serde-yaml` crate and defines the following additional function: 125 | 126 | * `parse_yaml(String) -> Dynamic` -- Takes YAML payload as string and returns 127 | the parsed payload as unstructured data (such as, RHAI object map or array). 128 | 129 | * `stringify_yaml(Dynamic) -> String` -- Convert a RHAI object to a YAML 130 | string, inverse of `parse_yaml`. 131 | 132 | ### `features = ["glob"]` 133 | 134 | Adds `glob` crate and defines the following additional function: 135 | 136 | * `glob(String) -> Vec` -- Takes a glob pattern and returns a list of paths that match it. 137 | 138 | ### `features = ["filesystem"]` 139 | 140 | Adds [`rhai-fs`](https://docs.rs/rhai-fs/latest/rhai_fs/) and defines the 141 | following additional function: 142 | 143 | * `basename(PathBuf) -> String` -- Returns the `.file_name()` of the given 144 | path, or the entire path if there is none. 145 | 146 | 147 | ## Examples 148 | 149 | Check out the [example crates](./example_crates) to see all of the above in action. 150 | 151 | ## License 152 | 153 | Licensed under the MIT, see [`./LICENSE`](./LICENSE). 154 | 155 | ## See also 156 | 157 | - [cargo-px](https://github.com/LukeMathWalker/cargo-px): A wrapper around 158 | cargo that extends its codegen abilities. 159 | 160 | - [libtest-mimic](https://github.com/LukasKalbertodt/libtest-mimic): An 161 | alternative test harness (completely removing `#[test]`) that allows you to 162 | generate tests programmatically. 163 | 164 | - [watt](https://github.com/dtolnay/watt): Write proc macros in webassembly. 165 | 166 | - [test-generator](https://docs.rs/test-generator/) is a simple way to produce 167 | one test per data file. 168 | 169 | - [my blog post on test parametrization](https://unterwaditzer.net/2023/rust-test-parametrization.html) 170 | -------------------------------------------------------------------------------- /example_crates/test-parametrization-with-files/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test-parametrization-with-files" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | script-macro = { version = "0.1.0", path = "../.." } 10 | -------------------------------------------------------------------------------- /example_crates/test-parametrization-with-files/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub fn add(left: usize, right: usize) -> usize { 2 | left + right 3 | } 4 | 5 | #[cfg(test)] 6 | mod tests { 7 | use super::*; 8 | 9 | #[script_macro::run_script_on(r##" 10 | let output = item; 11 | 12 | for testcase in parse_json(open_file("testcases.json").read_string()) { 13 | let a = testcase[0]; 14 | let b = testcase[1]; 15 | let result = testcase[2]; 16 | let fn_name = slugify_ident(`it_works_${a}_${b}`); 17 | output += ` 18 | #[test] 19 | fn ${fn_name}() { 20 | it_works(${a}, ${b}, ${result}); 21 | } 22 | `; 23 | } 24 | 25 | return output; 26 | "##)] 27 | fn it_works(a: usize, b: usize, output: usize) { 28 | assert_eq!(add(a, b), output); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example_crates/test-parametrization-with-files/testcases.json: -------------------------------------------------------------------------------- 1 | [ 2 | [1, 1, 2], 3 | [2, 3, 5] 4 | ] 5 | -------------------------------------------------------------------------------- /example_crates/test-parametrization/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test-parametrization" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | script-macro = { version = "0.1.0", path = "../.." } 10 | -------------------------------------------------------------------------------- /example_crates/test-parametrization/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub fn add(left: usize, right: usize) -> usize { 2 | left + right 3 | } 4 | 5 | #[cfg(test)] 6 | mod tests { 7 | use super::*; 8 | 9 | #[script_macro::run_script_on(r##" 10 | let output = item; 11 | 12 | for x in 0..10 { 13 | for y in 0..10 { 14 | output += ` 15 | #[test] 16 | fn it_works_${x}_${y}() { 17 | it_works(${x}, ${y}, ${x + y}); 18 | }`; 19 | } 20 | } 21 | 22 | return output; 23 | "##)] 24 | fn it_works(x: usize, y: usize, out: usize) { 25 | assert_eq!(add(x, y), out); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | extern crate proc_macro; 4 | 5 | use std::path::PathBuf; 6 | 7 | use proc_macro::TokenStream; 8 | use rhai::{Engine, EvalAltResult, ImmutableString, Position, Scope}; 9 | use syn::{ 10 | parse::{Parse, ParseStream}, 11 | parse_macro_input, LitStr, 12 | }; 13 | 14 | struct RunScriptInput { 15 | script_source: LitStr, 16 | } 17 | 18 | impl Parse for RunScriptInput { 19 | fn parse(input: ParseStream) -> syn::Result { 20 | Ok(Self { 21 | script_source: input.parse()?, 22 | }) 23 | } 24 | } 25 | 26 | fn get_source_context(source_code: &str, padding: usize, pos: Position) -> String { 27 | let mut source_snippet = String::new(); 28 | 29 | if let Some(lineno) = pos.line() { 30 | let lines: Vec<_> = source_code.split('\n').collect(); 31 | for (i, line) in lines 32 | [(lineno - padding).clamp(0, lines.len())..(lineno + padding).clamp(0, lines.len())] 33 | .iter() 34 | .enumerate() 35 | { 36 | if i == padding - 1 { 37 | source_snippet.push_str("--> "); 38 | } else { 39 | source_snippet.push_str(" "); 40 | } 41 | 42 | source_snippet.push_str(line); 43 | source_snippet.push('\n'); 44 | } 45 | } 46 | 47 | source_snippet 48 | } 49 | 50 | fn handle_runtime_error(source_code: &str, e: Box) { 51 | let pos = { 52 | let mut inner_error = &e; 53 | 54 | while let EvalAltResult::ErrorInModule(_, err, ..) 55 | | EvalAltResult::ErrorInFunctionCall(_, _, err, ..) = &**inner_error 56 | { 57 | inner_error = err; 58 | } 59 | 60 | inner_error.position() 61 | }; 62 | 63 | panic!("{}\n\n{}", e, get_source_context(source_code, 3, pos)); 64 | } 65 | 66 | #[proc_macro] 67 | pub fn run_script(params: TokenStream) -> TokenStream { 68 | let args = parse_macro_input!(params as RunScriptInput); 69 | 70 | let engine = get_default_engine(); 71 | let output: String = engine 72 | .eval(&args.script_source.value()) 73 | .map_err(|e| handle_runtime_error(&args.script_source.value(), e)) 74 | .unwrap(); 75 | 76 | output.parse().expect("invalid token stream") 77 | } 78 | 79 | #[proc_macro_attribute] 80 | pub fn run_script_on(params: TokenStream, item: TokenStream) -> TokenStream { 81 | let args = parse_macro_input!(params as RunScriptInput); 82 | let engine = get_default_engine(); 83 | 84 | let mut scope = Scope::new(); 85 | scope.push("item", item.to_string()); 86 | let output: String = engine 87 | .eval_with_scope(&mut scope, &args.script_source.value()) 88 | .map_err(|e| handle_runtime_error(&args.script_source.value(), e)) 89 | .unwrap(); 90 | 91 | output.parse().expect("invalid token stream") 92 | } 93 | 94 | fn get_default_engine() -> Engine { 95 | let mut engine = Engine::new(); 96 | 97 | engine.set_max_expr_depths(100, 100); 98 | 99 | #[cfg(feature = "parse-yaml")] 100 | engine.register_fn("parse_yaml", helper_parse_yaml); 101 | #[cfg(feature = "parse-json")] 102 | engine.register_fn("parse_json", helper_parse_json); 103 | #[cfg(feature = "parse-yaml")] 104 | engine.register_fn("stringify_yaml", helper_stringify_yaml); 105 | #[cfg(feature = "parse-json")] 106 | engine.register_fn("stringify_json", helper_stringify_json); 107 | engine.register_fn("slugify_ident", helper_slugify_ident); 108 | #[cfg(feature = "glob")] 109 | engine.register_fn("glob", helper_glob); 110 | engine.register_fn("basename", helper_basename); 111 | 112 | #[cfg(feature = "filesystem")] 113 | { 114 | use rhai::packages::Package; 115 | use rhai_fs::FilesystemPackage; 116 | let package = FilesystemPackage::new(); 117 | package.register_into_engine(&mut engine); 118 | } 119 | 120 | engine 121 | } 122 | 123 | #[cfg(any( 124 | feature = "parse-yaml", 125 | feature = "parse-json", 126 | feature = "filesystem", 127 | feature = "glob", 128 | ))] 129 | fn coerce_err(x: impl std::fmt::Debug) -> Box { 130 | format!("{x:?}").into() 131 | } 132 | 133 | #[cfg(feature = "parse-yaml")] 134 | fn helper_parse_yaml(input: ImmutableString) -> Result> { 135 | serde_yaml::from_str(input.as_str()).map_err(coerce_err) 136 | } 137 | 138 | #[cfg(feature = "parse-yaml")] 139 | fn helper_stringify_yaml(input: rhai::Dynamic) -> Result> { 140 | serde_yaml::to_string(&input) 141 | .map(From::from) 142 | .map_err(coerce_err) 143 | } 144 | 145 | #[cfg(feature = "parse-json")] 146 | fn helper_parse_json(input: ImmutableString) -> Result> { 147 | serde_json::from_str(input.as_str()).map_err(coerce_err) 148 | } 149 | 150 | #[cfg(feature = "parse-json")] 151 | fn helper_stringify_json(input: rhai::Dynamic) -> Result> { 152 | serde_json::to_string(&input) 153 | .map(From::from) 154 | .map_err(coerce_err) 155 | } 156 | 157 | fn helper_slugify_ident(input: ImmutableString) -> ImmutableString { 158 | let mut is_first_char = true; 159 | input 160 | .as_str() 161 | .replace( 162 | |x: char| { 163 | if is_first_char && x.is_ascii_digit() { 164 | return true; 165 | } 166 | is_first_char = false; 167 | 168 | !matches!(x, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_') 169 | }, 170 | "_", 171 | ) 172 | .into() 173 | } 174 | 175 | #[cfg(feature = "glob")] 176 | fn helper_glob(pattern: ImmutableString) -> Result> { 177 | let mut result = Vec::new(); 178 | 179 | for entry in glob::glob(pattern.as_str()).map_err(coerce_err)? { 180 | let entry = entry.map_err(coerce_err)?; 181 | 182 | result.push(entry); 183 | } 184 | 185 | Ok(result.into()) 186 | } 187 | 188 | fn helper_basename(input: PathBuf) -> Result> { 189 | Ok(input 190 | .file_name() 191 | .unwrap_or(input.as_os_str()) 192 | .to_str() 193 | .ok_or("basename is not valid unicode")? 194 | .to_owned() 195 | .into()) 196 | } 197 | --------------------------------------------------------------------------------