Flatten the defrun arglist to a sequence of names
`(defrun [{: sh}] body)` made the common case awkward and made
`(defrun [{:sh}] body)` look like a typo for the destructure shorthand
when it was actually an odd-element table literal — Fennel rejects it
at parse time, so the macro can't catch or improve the message.

Now `(defrun [sh secret] body)` names the keys directly; the macro
builds the destructure table itself. For renaming or nested patterns,
write the `let` by hand instead of using `defrun`.

Assisted-by: Claude Opus 4.7 via Claude Code
change rwkosvmvprmyvvxxuowxyywnyoornskx
commit 639b0204571efbfd466407a16d82cacf301f088f
author Alpha Chen <alpha@kejadlen.dev>
date
parent myvouvqu
diff --git a/quire-core/src/ci/macros.fnl b/quire-core/src/ci/macros.fnl
index 831a3b5..71d6a50 100644
--- a/quire-core/src/ci/macros.fnl
+++ b/quire-core/src/ci/macros.fnl
@@ -1,12 +1,11 @@
 ;; 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
-;; runtime. Writing `(let [{: sh} (. (require :quire.ci) :runtime)] …)`
-;; at the top of every job becomes the macro itself, with the
-;; destructure pattern as the apparent argument list.
+;; whose body needs some keys (`sh`, `secret`, `jobs`, `mirror`, …) from
+;; the runtime. The arglist names the keys to pull in, and the macro
+;; emits the `let` that binds them.
 ;;
-;;   (defrun [{: sh : jobs}]
+;;   (defrun [sh jobs]
 ;;     (let [push (jobs :quire/push)]
 ;;       (sh ["cargo" "test"])))
 ;;
@@ -20,17 +19,23 @@
 ;; An empty arglist skips the `let` entirely:
 ;;
 ;;   (defrun [] (do-something))  =>  (fn [] (do-something))
+;;
+;; For renaming, nested destructures, or anything else beyond a flat
+;; key-grab, write the `let` by hand instead of using `defrun`.
 
 (fn defrun [arglist ...]
-  (assert-compile (<= (length arglist) 1)
-                  "defrun expects an arglist with 0 or 1 destructure pattern"
-                  arglist)
-  (let [body [...]]
+  (let [body [...]
+        destructure {}]
+    (each [_ name (ipairs arglist)]
+      (assert-compile (sym? name)
+                      "defrun arglist must be a sequence of bare symbols naming runtime keys"
+                      name)
+      (tset destructure (tostring name) name))
     (if (= 0 (length arglist))
         `(fn []
            ,(unpack body))
         `(fn []
-           (let [,(. arglist 1) (. (require :quire.ci) :runtime)]
+           (let [,destructure (. (require :quire.ci) :runtime)]
              ,(unpack body))))))
 
 {: defrun}
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index ce141fd..4511675 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -480,7 +480,7 @@ mod tests {
         let f = fennel();
         let source = "\
 (import-macros {: defrun} :quire.ci)
-(defrun [{: sh}] nil)";
+(defrun [sh] nil)";
         let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
         assert!(
             matches!(value, mlua::Value::Function(_)),
@@ -493,7 +493,7 @@ mod tests {
         let f = fennel();
 
         // Populate the runtime stub with a `sh` that records calls.
-        // defrun expands to `(let [<pat> (. (require :quire.ci) :runtime)] …)`,
+        // defrun expands to `(let [{: sh} (. (require :quire.ci) :runtime)] …)`,
         // so the destructure pulls `sh` straight from this table.
         let calls: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
             std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
@@ -515,7 +515,7 @@ mod tests {
 
         let source = r#"
 (import-macros {: defrun} :quire.ci)
-(defrun [{: sh}] (sh :from-macro))
+(defrun [sh] (sh :from-macro))
 "#;
         let value = f.eval_raw(source, "test.fnl", |_| Ok(())).expect("eval");
         let mlua::Value::Function(func) = value else {
@@ -542,21 +542,65 @@ mod tests {
     }
 
     #[test]
-    fn defrun_rejects_multi_element_arglist() {
+    fn defrun_binds_multiple_names_from_runtime() {
+        let f = fennel();
+
+        // Populate stub with two callables that record which name was
+        // invoked, so the test proves both `sh` and `secret` reached
+        // the body.
+        let calls: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
+            std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
+        let package: mlua::Table = f.lua().globals().get("package").expect("package");
+        let loaded: mlua::Table = package.get("loaded").expect("package.loaded");
+        let ci: mlua::Table = loaded.get("quire.ci").expect("quire.ci placeholder");
+        let rt: mlua::Table = ci.get("runtime").expect("quire.ci.runtime stub");
+        for name in ["sh", "secret"] {
+            let cb_calls = calls.clone();
+            let label = name.to_string();
+            rt.set(
+                name,
+                f.lua()
+                    .create_function(move |_, ()| {
+                        cb_calls.borrow_mut().push(label.clone());
+                        Ok(())
+                    })
+                    .expect("create"),
+            )
+            .expect("set");
+        }
+
+        let source = r#"
+(import-macros {: defrun} :quire.ci)
+(defrun [sh secret] (sh) (secret))
+"#;
+        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!["sh".to_string(), "secret".to_string()]
+        );
+    }
+
+    #[test]
+    fn defrun_rejects_non_symbol_in_arglist() {
         let f = fennel();
         let source = r#"
 (import-macros {: defrun} :quire.ci)
-(defrun [a b] nil)
+(defrun [sh "secret"] nil)
 "#;
         let err = f
             .eval_raw(source, "test.fnl", |_| Ok(()))
-            .expect_err("multi-element arglist should fail to compile");
+            .expect_err("string in 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}"
+            combined.contains("bare symbols"),
+            "expected symbol-shape error, got: {combined}"
         );
     }