Add defrun macro for run-fn boilerplate
`(defrun [{: sh}] body)` expands to `(fn [] (let [{: sh} runtime]
body))`, so destructuring the ambient runtime stops being repeated at
every job. Macros and the existing runtime module share the `quire.ci`
module name; `fennel.macro-loaded` and `package.loaded` are independent
caches, no collision. The macro is itself the greppable effect-site
marker — looking for run-fns that touch the host is `defrun` plus any
remaining bare `runtime.X`.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/.quire/ci.fnl b/.quire/ci.fnl
index 688370f..03a0bce 100644
--- a/.quire/ci.fnl
+++ b/.quire/ci.fnl
@@ -1,6 +1,7 @@
(local {: job : mirror} (require :quire.ci))
+(import-macros {: defrun} :quire.ci)
-; (job :test [:quire/push] (fn [{: sh}] (sh [:cargo :test])))
+; (job :test [:quire/push] (defrun [{: sh}] (sh [:cargo :test])))
(mirror "https://github.com/kejadlen/quire.git"
{:refs [:refs/heads/main]
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index c055ac1..ab9bfcb 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -94,6 +94,29 @@ Run-fns are zero-arg functions. The runtime is available as a global `runtime` t
The `runtime.` prefix is a visible explicit-context marker so reviewers can grep for effect sites. Accessing `runtime` outside a run-fn raises: `runtime accessed outside a job — primitives are only available while a run-fn is executing`.
+When a run-fn destructures several primitives the `runtime.` prefix gets repetitive; the `defrun` macro takes the destructure pattern as its arglist and expands to the `let`-from-runtime shape:
+
+```
+(import-macros {: defrun} :quire.ci)
+
+(job :test [:quire/push]
+ (defrun [{: sh : jobs}]
+ (let [push (jobs :quire/push)]
+ (sh ["cargo" "test"]))))
+```
+
+expands to
+
+```
+(job :test [:quire/push]
+ (fn []
+ (let [{: sh : jobs} runtime]
+ (let [push (jobs :quire/push)]
+ (sh ["cargo" "test"])))))
+```
+
+`defrun` itself is the effect-site marker — reviewers grep for `defrun` (and any remaining bare `runtime.`) to find places that touch the host. The bare `(fn [] (runtime.X …))` form stays available for one-off use; the explicit `(let [{: sh} runtime] …)` form is also fine when you want the destructure visible without the macro.
+
> **v0 status:** `(jobs :quire/push)` is wired. Job-to-job outputs (where `(jobs :build)` returns a job's `run-fn` return value) are not — there's no writer API yet, and a reachable name with no recorded outputs returns `nil`.
### Sources
diff --git a/quire-core/src/ci/macros.fnl b/quire-core/src/ci/macros.fnl
new file mode 100644
index 0000000..c7251df
--- /dev/null
+++ b/quire-core/src/ci/macros.fnl
@@ -0,0 +1,36 @@
+;; quire.ci macros — imported via `(import-macros {: defrun} :quire.ci)`.
+;;
+;; `defrun` is sugar for the common run-fn shape: a zero-arg function
+;; whose body needs `sh` / `secret` / `jobs` / `mirror` from the
+;; ambient `runtime` global. Writing `(let [{: sh} runtime] …)` at the
+;; top of every job becomes the macro itself, with the destructure
+;; pattern as the apparent argument list.
+;;
+;; (defrun [{: sh : jobs}]
+;; (let [push (jobs :quire/push)]
+;; (sh ["cargo" "test"])))
+;;
+;; expands to:
+;;
+;; (fn []
+;; (let [{: sh : jobs} runtime]
+;; (let [push (jobs :quire/push)]
+;; (sh ["cargo" "test"]))))
+;;
+;; An empty arglist skips the `let` entirely:
+;;
+;; (defrun [] (do-something)) => (fn [] (do-something))
+
+(fn defrun [arglist ...]
+ (assert-compile (<= (length arglist) 1)
+ "defrun expects an arglist with 0 or 1 destructure pattern"
+ arglist)
+ (let [body [...]]
+ (if (= 0 (length arglist))
+ `(fn []
+ ,(unpack body))
+ `(fn []
+ (let [,(. arglist 1) runtime]
+ ,(unpack body))))))
+
+{: defrun}
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index bc1bfa6..1c1c991 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -12,6 +12,12 @@ const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
/// Fennel where they're easier to evolve.
const STDLIB_FNL: &str = include_str!("ci/stdlib.fnl");
+/// Embedded Fennel macros — exposed as `(import-macros {…} :quire.ci)`
+/// alongside the runtime-side `quire.ci` module. Same module name,
+/// different cache (`fennel.macro-loaded` vs `package.loaded`); the
+/// two namespaces are independent.
+const MACROS_FNL: &str = include_str!("ci/macros.fnl");
+
/// Error kinds from the Fennel loader.
#[derive(Debug, Error, Diagnostic)]
pub enum FennelError {
@@ -91,6 +97,7 @@ impl Fennel {
let f = Self { lua };
f.preload_stdlib()?;
+ f.preload_macros()?;
Ok(f)
}
@@ -105,6 +112,26 @@ impl Fennel {
Ok(())
}
+ /// Compile the embedded `quire.ci` macros and register them in
+ /// `fennel.macro-loaded` so `(import-macros {…} :quire.ci)`
+ /// resolves without hitting the filesystem. The macros file is
+ /// evaluated in the Fennel compiler environment (where
+ /// quasi-quote, `unpack`, `assert-compile`, etc. are bound).
+ fn preload_macros(&self) -> Result<(), FennelError> {
+ let fennel: mlua::Table = self.lua.globals().get("fennel")?;
+ let eval: mlua::Function = fennel.get("eval")?;
+ let opts = self.lua.create_table()?;
+ opts.set("filename", "quire.ci/macros.fnl")?;
+ opts.set("env", "_COMPILER")?;
+ opts.set("correlate", true)?;
+ let macros: mlua::Value = eval
+ .call((MACROS_FNL, opts))
+ .map_err(|e| FennelError::from_lua(MACROS_FNL, "quire.ci macros", e))?;
+ let macro_loaded: mlua::Table = fennel.get("macro-loaded")?;
+ macro_loaded.set("quire.ci", macros)?;
+ Ok(())
+ }
+
/// Borrow the underlying Lua VM. Useful for callers that need to
/// `to_value` / `from_value` against the same VM the Fennel script
/// ran in.
@@ -444,6 +471,87 @@ mod tests {
let _: mlua::Function = module.get("mirror").expect("mirror should be a function");
}
+ #[test]
+ fn defrun_macro_compiles_to_function() {
+ let f = fennel();
+ let source = "\
+(import-macros {: defrun} :quire.ci)
+(defrun [{: sh}] nil)";
+ let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
+ assert!(
+ matches!(value, mlua::Value::Function(_)),
+ "expected function, got {value:?}"
+ );
+ }
+
+ #[test]
+ fn defrun_destructures_from_ambient_runtime() {
+ let f = fennel();
+
+ // Replace the stub with a runtime table whose `sh` records calls.
+ let calls: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
+ std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
+ let cb_calls = calls.clone();
+ let rt = f.lua().create_table().expect("rt");
+ rt.set(
+ "sh",
+ f.lua()
+ .create_function(move |_, cmd: String| {
+ cb_calls.borrow_mut().push(cmd);
+ Ok(())
+ })
+ .expect("create sh"),
+ )
+ .expect("set sh");
+ f.lua().globals().set("runtime", rt).expect("set runtime");
+
+ let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [{: sh}] (sh :from-macro))
+"#;
+ let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
+ let mlua::Value::Function(func) = value else {
+ panic!("expected function, got {value:?}");
+ };
+ func.call::<()>(()).expect("call");
+
+ assert_eq!(*calls.borrow(), vec!["from-macro".to_string()]);
+ }
+
+ #[test]
+ fn defrun_with_empty_arglist_skips_destructure() {
+ let f = fennel();
+ let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [] 42)
+"#;
+ let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
+ let mlua::Value::Function(func) = value else {
+ panic!("expected function, got {value:?}");
+ };
+ let result: i64 = func.call(()).expect("call");
+ assert_eq!(result, 42);
+ }
+
+ #[test]
+ fn defrun_rejects_multi_element_arglist() {
+ let f = fennel();
+ let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [a b] nil)
+"#;
+ let err = f
+ .eval_raw(source, "test.fnl", |_| Ok(()))
+ .expect_err("multi-element arglist should fail to compile");
+ let msg = err.to_string();
+ let chain = format!("{err:?}");
+ let combined = format!("{msg} {chain}");
+ assert!(
+ combined.contains("defrun expects"),
+ "expected arity error, got: {combined}"
+ );
+ }
+
#[test]
fn quire_runtime_module_preloaded_to_stub() {
let f = fennel();