Expose quire.ci as a plain table of functions
Switch the CiModule from a UserData with methods to a plain Lua table
whose entries are bare functions. The module installs itself on the
Lua VM via set_app_data, and the registered functions look it back up
at call time — no closure captures, no per-function clones. Reads
naturally as either field access ((ci.job …)) or destructured
((local {: job : secret} (require :quire.ci))), which matches how the
user-facing CI-FENNEL.md examples were already written.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 7c7c16f..17f68f5 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -5,7 +5,7 @@ use std::collections::HashMap;
use std::rc::Rc;
use miette::{NamedSource, SourceSpan};
-use mlua::UserData;
+use mlua::Lua;
use crate::Result;
use crate::fennel::Fennel;
@@ -21,7 +21,7 @@ use crate::secret::SecretString;
pub struct Job {
pub id: String,
pub inputs: Vec<String>,
- /// Span covering the `(ci:job …)` call site. Used as the label
+ /// Span covering the `(ci.job …)` call site. Used as the label
/// location for both per-job and post-graph diagnostics.
pub(crate) span: SourceSpan,
/// The job's run function from the Lua VM.
@@ -31,7 +31,7 @@ pub struct Job {
}
impl Job {
- /// Build a `Job` from the raw `(ci:job …)` arguments, applying the
+ /// Build a `Job` from the raw `(ci.job …)` arguments, applying the
/// per-job validation rules. `line` is the 1-indexed source line of
/// the call site; `source` is the full Fennel source string used to
/// compute the diagnostic span.
@@ -79,12 +79,15 @@ impl Pipeline {
/// The `quire.ci` module exposed to Fennel scripts via `require`.
///
-/// Registered as `package.loaded["quire.ci"]` so scripts can write:
+/// `install` stows the module on the Lua VM via `set_app_data`, then
+/// builds a plain table whose entries are bare functions that look the
+/// module back up at call time. Both `(ci.job …)` field access and
+/// `(local {: job : secret} (require :quire.ci))` destructuring work.
///
/// ```fennel
/// (local ci (require :quire.ci))
-/// (ci:job :build [:quire/push] (fn [_] nil))
-/// (ci:secret :github_token)
+/// (ci.job :build [:quire/push] (fn [_] nil))
+/// (ci.secret :github_token)
/// ```
struct CiModule {
jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
@@ -92,39 +95,60 @@ struct CiModule {
secrets: Rc<HashMap<String, SecretString>>,
}
-impl UserData for CiModule {
- fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
- methods.add_method(
- "job",
- |lua, this, (id, inputs, run_fn): (String, Vec<String>, mlua::Function)| {
- let line = lua
- .inspect_stack(1, |d| d.current_line())
- .flatten()
- .map(|l| l as u32)
- .unwrap_or(0);
- this.jobs
- .borrow_mut()
- .push(Job::new(id, inputs, run_fn, line, &this.source));
- Ok(())
- },
- );
-
- // (ci:secret :name) — resolve a named secret declared in global
- // config and return the string value. Errors as a Lua error if
- // the name is undeclared or the file form fails to read.
- methods.add_method("secret", |_, this, name: String| {
- let secret = this
- .secrets
- .get(&name)
- .ok_or_else(|| mlua::Error::external(crate::Error::UnknownSecret(name)))?;
- secret
- .reveal()
- .map(|s| s.to_string())
- .map_err(mlua::Error::external)
- });
+impl CiModule {
+ /// Install the module on `lua` as app data and return the
+ /// `quire.ci` table. The registered functions error at call time
+ /// if the module isn't installed first.
+ fn install(self, lua: &Lua) -> mlua::Result<mlua::Table> {
+ lua.set_app_data(self);
+ let table = lua.create_table()?;
+ table.set("job", lua.create_function(register_job)?)?;
+ table.set("secret", lua.create_function(lookup_secret)?)?;
+ Ok(table)
}
}
+/// Pull the `CiModule` off the Lua VM's app data. Errors with a
+/// reasonable message if `install` was never called — should be
+/// impossible in practice but worth surfacing if it ever happens.
+fn module(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, CiModule>> {
+ lua.app_data_ref::<CiModule>()
+ .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))
+}
+
+/// Body of `(ci.job id inputs run-fn)`. Captures the call-site line
+/// from the Lua debug stack so per-job validation errors carry a span
+/// pointing back at the user's source.
+fn register_job(
+ lua: &Lua,
+ (id, inputs, run_fn): (String, Vec<String>, mlua::Function),
+) -> mlua::Result<()> {
+ let m = module(lua)?;
+ let line = lua
+ .inspect_stack(1, |d| d.current_line())
+ .flatten()
+ .map(|l| l as u32)
+ .unwrap_or(0);
+ m.jobs
+ .borrow_mut()
+ .push(Job::new(id, inputs, run_fn, line, &m.source));
+ Ok(())
+}
+
+/// Body of `(ci.secret name)`. Errors as a Lua error if the name is
+/// undeclared or the file form fails to read.
+fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
+ let m = module(lua)?;
+ let secret = m
+ .secrets
+ .get(&name)
+ .ok_or_else(|| mlua::Error::external(crate::Error::UnknownSecret(name)))?;
+ secret
+ .reveal()
+ .map(|s| s.to_string())
+ .map_err(mlua::Error::external)
+}
+
/// Parse and validate a ci.fnl source string into a `Pipeline`.
///
/// Injects `quire.ci` into `package.loaded` so scripts can
@@ -167,7 +191,7 @@ pub(crate) fn load(
}
/// Evaluate `source` with the `quire.ci` module bound and collect the
-/// registration results — one `Result` per `(ci:job …)` call. Pre-graph
+/// registration results — one `Result` per `(ci.job …)` call. Pre-graph
/// rules run inside the callback, so a single bad job does not abort
/// the rest of the script.
fn parse(
@@ -182,15 +206,14 @@ fn parse(
let secrets = Rc::new(secrets);
fennel.eval_raw(source, filename, display, |lua| {
+ let module = CiModule {
+ jobs: jobs.clone(),
+ source: src.clone(),
+ secrets: secrets.clone(),
+ }
+ .install(lua)?;
let loaded: mlua::Table = lua.globals().get::<mlua::Table>("package")?.get("loaded")?;
- loaded.set(
- "quire.ci",
- CiModule {
- jobs: jobs.clone(),
- source: src.clone(),
- secrets: secrets.clone(),
- },
- )?;
+ loaded.set("quire.ci", module)?;
Ok(())
})?;
@@ -273,7 +296,7 @@ pub struct LoadError {
/// reachability — over the surviving jobs from parsing.
///
/// Per-job pre-graph rules (slash-in-id, empty inputs) run inside the
-/// `(ci:job …)` callback during `parse`, so they are not re-checked here.
+/// `(ci.job …)` callback during `parse`, so they are not re-checked here.
fn validate_post_graph(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
@@ -366,7 +389,7 @@ mod tests {
fn load_registers_a_job() {
let f = fennel();
let source = r#"(local ci (require :quire.ci))
-(ci:job :test [:quire/push] (fn [_] nil))"#;
+(ci.job :test [:quire/push] (fn [_] nil))"#;
let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 1);
@@ -379,8 +402,8 @@ mod tests {
let f = fennel();
let source = r#"
(local ci (require :quire.ci))
-(ci:job :build [:quire/push] (fn [_] nil))
-(ci:job :test [:build] (fn [_] nil))
+(ci.job :build [:quire/push] (fn [_] nil))
+(ci.job :test [:build] (fn [_] nil))
"#;
let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
let jobs = pipeline.jobs();
@@ -395,11 +418,11 @@ mod tests {
fn load_captures_source_line() {
let f = fennel();
let source = "(local ci (require :quire.ci))
-(ci:job :first [:quire/push] (fn [_] nil))
-(ci:job :second [:quire/push] (fn [_] nil))
+(ci.job :first [:quire/push] (fn [_] nil))
+(ci.job :second [:quire/push] (fn [_] nil))
-(ci:job :sixth [:quire/push] (fn [_] nil))";
+(ci.job :sixth [:quire/push] (fn [_] nil))";
let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
let lines: Vec<usize> = pipeline
.jobs()
@@ -438,8 +461,8 @@ mod tests {
let jobs = parsed_jobs(
r#"
(local ci (require :quire.ci))
-(ci:job :build [:quire/push] (fn [_] nil))
-(ci:job :test [:build :quire/push] (fn [_] nil))
+(ci.job :build [:quire/push] (fn [_] nil))
+(ci.job :test [:build :quire/push] (fn [_] nil))
"#,
);
assert!(validate_post_graph(&jobs).is_ok());
@@ -450,8 +473,8 @@ mod tests {
let jobs = parsed_jobs(
r#"
(local ci (require :quire.ci))
-(ci:job :a [:b] (fn [_] nil))
-(ci:job :b [:a] (fn [_] nil))
+(ci.job :a [:b] (fn [_] nil))
+(ci.job :b [:a] (fn [_] nil))
"#,
);
let errs = validate_post_graph(&jobs).unwrap_err();
@@ -466,9 +489,9 @@ mod tests {
let jobs = parsed_jobs(
r#"
(local ci (require :quire.ci))
-(ci:job :a [:b :quire/push] (fn [_] nil))
-(ci:job :b [:a :quire/push] (fn [_] nil))
-(ci:job :clean [:quire/push] (fn [_] nil))
+(ci.job :a [:b :quire/push] (fn [_] nil))
+(ci.job :b [:a :quire/push] (fn [_] nil))
+(ci.job :clean [:quire/push] (fn [_] nil))
"#,
);
let errs = validate_post_graph(&jobs).unwrap_err();
@@ -492,10 +515,10 @@ mod tests {
let jobs = parsed_jobs(
r#"
(local ci (require :quire.ci))
-(ci:job :a [:b :quire/push] (fn [_] nil))
-(ci:job :b [:a :quire/push] (fn [_] nil))
-(ci:job :c [:d :quire/push] (fn [_] nil))
-(ci:job :d [:c :quire/push] (fn [_] nil))
+(ci.job :a [:b :quire/push] (fn [_] nil))
+(ci.job :b [:a :quire/push] (fn [_] nil))
+(ci.job :c [:d :quire/push] (fn [_] nil))
+(ci.job :d [:c :quire/push] (fn [_] nil))
"#,
);
let errs = validate_post_graph(&jobs).unwrap_err();
@@ -510,7 +533,7 @@ mod tests {
fn parse_rejects_empty_inputs() {
let results = parse_results(
r#"(local ci (require :quire.ci))
-(ci:job :setup [] (fn [_] nil))"#,
+(ci.job :setup [] (fn [_] nil))"#,
);
assert!(
results.iter().any(
@@ -524,7 +547,7 @@ mod tests {
fn parse_rejects_slash_in_job_id() {
let results = parse_results(
r#"(local ci (require :quire.ci))
-(ci:job :foo/bar [:quire/push] (fn [_] nil))"#,
+(ci.job :foo/bar [:quire/push] (fn [_] nil))"#,
);
assert!(
results.iter().any(
@@ -543,7 +566,7 @@ mod tests {
SecretString::from_plain("ghp_test_value"),
);
let source = r#"(local ci (require :quire.ci))
-(ci:job :grab [:quire/push] (fn [_] (ci:secret :github_token)))"#;
+(ci.job :grab [:quire/push] (fn [_] (ci.secret :github_token)))"#;
let pipeline = load(&f, source, "ci.fnl", "ci.fnl", secrets)
.expect("load should succeed");
let token: String = pipeline.jobs()[0]
@@ -557,7 +580,7 @@ mod tests {
fn ci_secret_errors_for_unknown_name() {
let f = fennel();
let source = r#"(local ci (require :quire.ci))
-(ci:job :grab [:quire/push] (fn [_] (ci:secret :missing)))"#;
+(ci.job :grab [:quire/push] (fn [_] (ci.secret :missing)))"#;
let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new())
.expect("load should succeed");
let err = pipeline.jobs()[0]
@@ -577,7 +600,7 @@ mod tests {
// and reaches the post-graph reachability check.
let jobs = parsed_jobs(
r#"(local ci (require :quire.ci))
-(ci:job :orphan [:orphan] (fn [_] nil))"#,
+(ci.job :orphan [:orphan] (fn [_] nil))"#,
);
let errs = validate_post_graph(&jobs).unwrap_err();
assert!(