use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use alba_syntax::{
BeamDecl, BeamRef, BinOp, ExecutorDecl, ExecutorOptionValue, Expr, File, ParseError, Span,
Spanned, StringTemplate, TemplatePart,
};
use crate::error::{CoreError, ROOT_SOURCE_ID, SourceIdScope, current_source_id};
use crate::git::{GitError, GitHead};
use crate::model::{Beam, BeamId, ExecutorKind, Hook, OptionValue, Project, Value};
const BUILTIN_FUNCTIONS: &[&str] = &["env", "glob"];
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Scope {
values: HashMap<String, Value>,
git: Option<Arc<LazyGit>>,
}
#[derive(Debug)]
pub(crate) struct LazyGit {
root: PathBuf,
head: OnceLock<Result<GitHead, GitError>>,
}
impl LazyGit {
pub(crate) fn new(root: PathBuf) -> Self {
Self {
root,
head: OnceLock::new(),
}
}
fn head(&self) -> Result<&GitHead, GitError> {
self.head
.get_or_init(|| crate::git::head(&self.root))
.as_ref()
.map_err(Clone::clone)
}
}
impl PartialEq for LazyGit {
fn eq(&self, other: &Self) -> bool {
self.root == other.root
}
}
impl Scope {
pub fn empty() -> Self {
Self::default()
}
pub(crate) fn with_git(git: Arc<LazyGit>) -> Self {
Self {
values: HashMap::new(),
git: Some(git),
}
}
fn git_field(&self, field: &Spanned<String>, span: Span) -> Result<Value, CoreError> {
let Some(git) = &self.git else {
return Err(CoreError::new("`git` is not available here", span));
};
let head = git.head().map_err(|error| {
CoreError::new(format!("cannot read `git.{}`: {error}", field.value), span)
})?;
Ok(match field.value.as_str() {
"branch" => Value::Str(head.branch.clone()),
"sha" => Value::Str(head.sha.clone()),
"short_sha" => Value::Str(head.short_sha.clone()),
"dirty" => Value::Bool(head.dirty),
_ => unreachable!("checked by check_field before this method is ever called"),
})
}
pub(crate) fn insert(&mut self, name: String, value: Value) {
self.values.insert(name, value);
}
pub fn get(&self, name: &str) -> Option<&Value> {
self.values.get(name)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.values.keys().map(String::as_str)
}
pub fn with_params(&self, params: &[String], args: &[String]) -> Self {
let mut values = self.values.clone();
for (name, arg) in params.iter().zip(args.iter()) {
values.insert(name.clone(), Value::Str(arg.clone()));
}
Self {
values,
git: self.git.clone(),
}
}
}
fn expr_span(expr: &Expr) -> Option<Span> {
match expr {
Expr::Str(t) => Some(t.span),
Expr::Var(name) => Some(name.span),
Expr::Call { name, .. } => Some(name.span),
Expr::Bool(_) | Expr::Binary { .. } | Expr::If { .. } => None,
Expr::Field { object, field } => Some(Span::new(object.span.start, field.span.end)),
}
}
fn check_field(object: &Spanned<String>, field: &Spanned<String>) -> Result<(), CoreError> {
if object.value != "git" {
return Err(CoreError::new(
format!("`{}` has no fields; only `git` does", object.value),
object.span,
));
}
if !matches!(
field.value.as_str(),
"branch" | "sha" | "short_sha" | "dirty"
) {
return Err(
CoreError::new(format!("unknown git field `{}`", field.value), field.span)
.with_help("expected one of `branch`, `sha`, `short_sha`, `dirty`"),
);
}
Ok(())
}
fn eval_field(
object: &Spanned<String>,
field: &Spanned<String>,
scope: &Scope,
) -> Result<Value, CoreError> {
check_field(object, field)?;
scope.git_field(field, Span::new(object.span.start, field.span.end))
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for i in 1..=a.len() {
let mut prev_diag = row[0];
row[0] = i;
for j in 1..=b.len() {
let temp = row[j];
row[j] = if a[i - 1] == b[j - 1] {
prev_diag
} else {
1 + prev_diag.min(row[j]).min(row[j - 1])
};
prev_diag = temp;
}
}
row[b.len()]
}
pub fn suggest<'a>(name: &str, candidates: impl Iterator<Item = &'a str>) -> Option<&'a str> {
let mut scored: Vec<(&str, usize)> = candidates
.map(|candidate| (candidate, levenshtein(name, candidate)))
.filter(|&(_, distance)| distance <= 2)
.collect();
scored.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0)));
scored.into_iter().next().map(|(candidate, _)| candidate)
}
fn unknown_variable_error<'a>(
name: &Spanned<String>,
candidates: impl Iterator<Item = &'a str>,
) -> CoreError {
let err = CoreError::new(format!("unknown variable `{}`", name.value), name.span);
match suggest(&name.value, candidates) {
Some(c) => err.with_help(format!("did you mean `{c}`?")),
None => err,
}
}
fn unknown_function_error(name: &Spanned<String>) -> CoreError {
let err = CoreError::new(format!("unknown function `{}`", name.value), name.span);
match suggest(&name.value, BUILTIN_FUNCTIONS.iter().copied()) {
Some(c) => err.with_help(format!("did you mean `{c}`?")),
None => err,
}
}
pub fn render_template(template: &StringTemplate, scope: &Scope) -> Result<String, CoreError> {
let mut out = String::new();
for part in &template.parts {
match part {
TemplatePart::Literal(s) => out.push_str(s),
TemplatePart::Expr(e) => {
let fallback = expr_span(e).unwrap_or(template.span);
let value = eval_with_fallback(e, scope, fallback)?;
out.push_str(value.as_display());
}
}
}
Ok(out)
}
pub fn eval_expr(expr: &Expr, scope: &Scope) -> Result<Value, CoreError> {
let fallback = expr_span(expr).unwrap_or(Span::new(0, 0));
eval_with_fallback(expr, scope, fallback)
}
fn eval_with_fallback(expr: &Expr, scope: &Scope, fallback: Span) -> Result<Value, CoreError> {
match expr {
Expr::Str(t) => Ok(Value::Str(render_template(t, scope)?)),
Expr::Bool(b) => Ok(Value::Bool(*b)),
Expr::Var(name) => scope
.get(&name.value)
.cloned()
.ok_or_else(|| unknown_variable_error(name, scope.names())),
Expr::Call { name, args } => eval_call(name, args, scope, fallback),
Expr::Binary { op, lhs, rhs } => {
let lv = eval_with_fallback(lhs, scope, expr_span(lhs).unwrap_or(fallback))?;
let rv = eval_with_fallback(rhs, scope, expr_span(rhs).unwrap_or(fallback))?;
eval_binary(*op, lv, rv, fallback)
}
Expr::If {
cond,
then,
otherwise,
} => {
let cond_span = expr_span(cond).unwrap_or(fallback);
match eval_with_fallback(cond, scope, cond_span)? {
Value::Bool(true) => {
eval_with_fallback(then, scope, expr_span(then).unwrap_or(fallback))
}
Value::Bool(false) => {
eval_with_fallback(otherwise, scope, expr_span(otherwise).unwrap_or(fallback))
}
other => Err(CoreError::new(
format!(
"expected a boolean condition for `if`, found {}",
other.type_name()
),
cond_span,
)),
}
}
Expr::Field { object, field } => eval_field(object, field, scope),
}
}
fn eval_binary(op: BinOp, lhs: Value, rhs: Value, span: Span) -> Result<Value, CoreError> {
match op {
BinOp::Concat => match (lhs, rhs) {
(Value::Str(a), Value::Str(b)) => Ok(Value::Str(a + &b)),
(l, r) => Err(CoreError::new(
format!(
"`+` requires two strings, found {} and {}",
l.type_name(),
r.type_name()
),
span,
)),
},
BinOp::Eq | BinOp::NotEq => {
if std::mem::discriminant(&lhs) != std::mem::discriminant(&rhs) {
return Err(CoreError::new(
format!(
"`==`/`!=` requires operands of the same type, found {} and {}",
lhs.type_name(),
rhs.type_name()
),
span,
));
}
let equal = lhs == rhs;
Ok(Value::Bool(if op == BinOp::Eq { equal } else { !equal }))
}
BinOp::And | BinOp::Or => match (lhs, rhs) {
(Value::Bool(a), Value::Bool(b)) => {
Ok(Value::Bool(if op == BinOp::And { a && b } else { a || b }))
}
(l, r) => Err(CoreError::new(
format!(
"`&&`/`||` requires two booleans, found {} and {}",
l.type_name(),
r.type_name()
),
span,
)),
},
}
}
fn eval_call(
name: &Spanned<String>,
args: &[Expr],
scope: &Scope,
fallback: Span,
) -> Result<Value, CoreError> {
match name.value.as_str() {
"env" => eval_env(name, args, scope, fallback),
"glob" => eval_glob(name, args, scope, fallback),
_ => Err(unknown_function_error(name)),
}
}
fn arity_error(name: &Spanned<String>, min: usize, max: usize, found: usize) -> CoreError {
let expected = if min == max {
format!("{min}")
} else {
format!("{min} to {max}")
};
CoreError::new(
format!(
"`{}` expects {expected} argument(s), found {found}",
name.value
),
name.span,
)
}
fn expect_str_value(fn_name: &str, value: Value, span: Span) -> Result<String, CoreError> {
match value {
Value::Str(s) => Ok(s),
other => Err(CoreError::new(
format!(
"argument to `{fn_name}` must be a string, found {}",
other.type_name()
),
span,
)),
}
}
fn expect_str_arg(
fn_name: &Spanned<String>,
arg: &Expr,
scope: &Scope,
fallback: Span,
) -> Result<String, CoreError> {
let span = expr_span(arg).unwrap_or(fallback);
let value = eval_with_fallback(arg, scope, span)?;
expect_str_value(&fn_name.value, value, span)
}
fn eval_env(
name: &Spanned<String>,
args: &[Expr],
scope: &Scope,
fallback: Span,
) -> Result<Value, CoreError> {
if args.is_empty() || args.len() > 2 {
return Err(arity_error(name, 1, 2, args.len()));
}
let key = expect_str_arg(name, &args[0], scope, fallback)?;
match std::env::var(&key) {
Ok(v) => Ok(Value::Str(v)),
Err(_) => match args.get(1) {
Some(default_expr) => {
let span = expr_span(default_expr).unwrap_or(fallback);
eval_with_fallback(default_expr, scope, span)
}
None => Err(CoreError::new(
format!("environment variable `{key}` is not set"),
name.span,
)),
},
}
}
fn validate_glob_pattern(pattern: String, span: Span) -> Result<Value, CoreError> {
glob::Pattern::new(&pattern)
.map_err(|e| CoreError::new(format!("invalid glob pattern `{pattern}`: {e}"), span))?;
Ok(Value::Str(pattern))
}
fn eval_glob(
name: &Spanned<String>,
args: &[Expr],
scope: &Scope,
fallback: Span,
) -> Result<Value, CoreError> {
if args.len() != 1 {
return Err(arity_error(name, 1, 1, args.len()));
}
let pattern_span = expr_span(&args[0]).unwrap_or(fallback);
let pattern = expect_str_arg(name, &args[0], scope, fallback)?;
validate_glob_pattern(pattern, pattern_span)
}
enum StaticValue {
Known(Value),
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Timing {
Load,
Schedule,
}
fn check_expr(
expr: &Expr,
lets: &Scope,
params: &[String],
timing: Timing,
fallback: Span,
) -> Result<StaticValue, CoreError> {
match expr {
Expr::Bool(b) => Ok(StaticValue::Known(Value::Bool(*b))),
Expr::Var(name) => {
if params.iter().any(|p| p == &name.value) {
Ok(StaticValue::Unknown)
} else if let Some(v) = lets.get(&name.value) {
Ok(StaticValue::Known(v.clone()))
} else {
let candidates = lets.names().chain(params.iter().map(String::as_str));
Err(unknown_variable_error(name, candidates))
}
}
Expr::Str(template) => {
let mut any_unknown = false;
let mut rendered = String::new();
for part in &template.parts {
match part {
TemplatePart::Literal(s) => rendered.push_str(s),
TemplatePart::Expr(e) => {
let span = expr_span(e).unwrap_or(template.span);
match check_expr(e, lets, params, timing, span)? {
StaticValue::Known(v) => rendered.push_str(v.as_display()),
StaticValue::Unknown => any_unknown = true,
}
}
}
}
if any_unknown {
Ok(StaticValue::Unknown)
} else {
Ok(StaticValue::Known(Value::Str(rendered)))
}
}
Expr::Call { name, args } => match name.value.as_str() {
"env" => check_env_call(name, args, lets, params, timing, fallback),
"glob" => check_glob_call(name, args, lets, params, timing, fallback),
_ => Err(unknown_function_error(name)),
},
Expr::Binary { op, lhs, rhs } => {
let l = check_expr(
lhs,
lets,
params,
timing,
expr_span(lhs).unwrap_or(fallback),
)?;
let r = check_expr(
rhs,
lets,
params,
timing,
expr_span(rhs).unwrap_or(fallback),
)?;
match (l, r) {
(StaticValue::Known(lv), StaticValue::Known(rv)) => {
Ok(StaticValue::Known(eval_binary(*op, lv, rv, fallback)?))
}
_ => Ok(StaticValue::Unknown),
}
}
Expr::If {
cond,
then,
otherwise,
} => {
let cond_span = expr_span(cond).unwrap_or(fallback);
let then_span = expr_span(then).unwrap_or(fallback);
let otherwise_span = expr_span(otherwise).unwrap_or(fallback);
match check_expr(cond, lets, params, timing, cond_span)? {
StaticValue::Known(Value::Bool(cond_value)) => {
let then_result = check_expr(then, lets, params, timing, then_span)?;
let else_result = check_expr(otherwise, lets, params, timing, otherwise_span)?;
Ok(if cond_value { then_result } else { else_result })
}
StaticValue::Known(other) => Err(CoreError::new(
format!(
"expected a boolean condition for `if`, found {}",
other.type_name()
),
cond_span,
)),
StaticValue::Unknown => {
check_expr(then, lets, params, timing, then_span)?;
check_expr(otherwise, lets, params, timing, otherwise_span)?;
Ok(StaticValue::Unknown)
}
}
}
Expr::Field { object, field } => {
check_field(object, field)?;
Ok(StaticValue::Unknown)
}
}
}
fn check_env_call(
name: &Spanned<String>,
args: &[Expr],
lets: &Scope,
params: &[String],
timing: Timing,
fallback: Span,
) -> Result<StaticValue, CoreError> {
if args.is_empty() || args.len() > 2 {
return Err(arity_error(name, 1, 2, args.len()));
}
for arg in args {
let span = expr_span(arg).unwrap_or(fallback);
check_expr(arg, lets, params, timing, span)?;
}
if timing == Timing::Load && args.len() == 1 {
return Err(CoreError::new(
"`env` requires a default value here",
name.span,
)
.with_help(
"add one, e.g. `env(\"NAME\", \"fallback\")`, or read the variable from `run`/`env` \
instead, where it resolves when the beam runs",
));
}
Ok(StaticValue::Unknown)
}
fn check_glob_call(
name: &Spanned<String>,
args: &[Expr],
lets: &Scope,
params: &[String],
timing: Timing,
fallback: Span,
) -> Result<StaticValue, CoreError> {
if args.len() != 1 {
return Err(arity_error(name, 1, 1, args.len()));
}
let pattern_span = expr_span(&args[0]).unwrap_or(fallback);
match check_expr(&args[0], lets, params, timing, pattern_span)? {
StaticValue::Known(value) => {
let pattern = expect_str_value(&name.value, value, pattern_span)?;
Ok(StaticValue::Known(validate_glob_pattern(
pattern,
pattern_span,
)?))
}
StaticValue::Unknown => Ok(StaticValue::Unknown),
}
}
fn validate_template(
template: &StringTemplate,
lets: &Scope,
params: &[String],
timing: Timing,
) -> Result<(), CoreError> {
for part in &template.parts {
if let TemplatePart::Expr(e) = part {
let span = expr_span(e).unwrap_or(template.span);
check_expr(e, lets, params, timing, span)?;
}
}
Ok(())
}
fn reject_param_references(expr: &Expr, params: &[String], field: &str) -> Result<(), CoreError> {
match expr {
Expr::Bool(_) => Ok(()),
Expr::Var(name) => {
if params.iter().any(|p| p == &name.value) {
Err(CoreError::new(
format!("beam parameters cannot be used in `{field}`"),
name.span,
))
} else {
Ok(())
}
}
Expr::Str(template) => reject_param_references_in_template(template, params, field),
Expr::Call { args, .. } => args
.iter()
.try_for_each(|arg| reject_param_references(arg, params, field)),
Expr::Binary { lhs, rhs, .. } => {
reject_param_references(lhs, params, field)?;
reject_param_references(rhs, params, field)
}
Expr::If {
cond,
then,
otherwise,
} => {
reject_param_references(cond, params, field)?;
reject_param_references(then, params, field)?;
reject_param_references(otherwise, params, field)
}
Expr::Field { .. } => Ok(()),
}
}
fn reject_param_references_in_template(
template: &StringTemplate,
params: &[String],
field: &str,
) -> Result<(), CoreError> {
template.parts.iter().try_for_each(|part| match part {
TemplatePart::Literal(_) => Ok(()),
TemplatePart::Expr(e) => reject_param_references(e, params, field),
})
}
fn render_load_time_template(
field: &str,
template: &StringTemplate,
lets: &Scope,
params: &[String],
) -> Result<String, CoreError> {
reject_param_references_in_template(template, params, field)?;
validate_template(template, lets, &[], Timing::Load)?;
render_template(template, lets)
}
pub(crate) fn parse_error_to_core_error(e: ParseError) -> CoreError {
let err = CoreError::new(e.message, e.span);
match e.help {
Some(help) => err.with_help(help),
None => err,
}
}
#[doc(hidden)]
pub fn load_str(source: &str) -> Result<Project, CoreError> {
let _scope = SourceIdScope::enter(ROOT_SOURCE_ID);
let file = alba_syntax::parse(source).map_err(parse_error_to_core_error)?;
if let Some(import) = file.imports.first() {
return Err(CoreError::new(
"imports are not supported by `load_str`; use `load_project` instead",
import.path.span,
));
}
let project = build_project(
&file,
Path::new("."),
Arc::new(LazyGit::new(PathBuf::from("."))),
true,
)?;
crate::graph::validate_graph(&project)?;
Ok(project)
}
pub(crate) fn build_project(
file: &File,
dir: &Path,
git: Arc<LazyGit>,
is_root: bool,
) -> Result<Project, CoreError> {
let mut lets = Scope::with_git(git);
for binding in &file.lets {
let fallback = expr_span(&binding.value).unwrap_or(binding.name.span);
check_expr(&binding.value, &lets, &[], Timing::Load, fallback)?;
let value = eval_expr(&binding.value, &lets)?;
lets.insert(binding.name.value.clone(), value);
}
let beams = file
.beams
.iter()
.map(|decl| build_beam(decl, &lets, dir))
.collect::<Result<Vec<_>, _>>()?;
let default = file
.default
.as_ref()
.map(|d| Spanned::new(BeamId(d.value.clone()), d.span));
let mut hooks: Vec<Hook> = Vec::new();
if is_root {
for decl in &file.hooks {
let Some(known) = crate::git::known_hook(&decl.name.value) else {
let mut err = CoreError::new(
format!("unknown git hook `{}`", decl.name.value),
decl.name.span,
);
if let Some(candidate) = suggest(
&decl.name.value,
crate::git::KNOWN_HOOKS.iter().map(|hook| hook.name),
) {
err = err.with_help(format!("did you mean `{candidate}`?"));
}
return Err(err);
};
if hooks.iter().any(|hook| hook.name == decl.name.value) {
return Err(CoreError::new(
format!("duplicate hook `{}`", decl.name.value),
decl.name.span,
));
}
hooks.push(Hook {
name: decl.name.value.clone(),
beam: Spanned::new(beam_ref_to_id(&decl.beam.value), decl.beam.span),
arity: known.arity,
span: decl.span,
source: current_source_id(),
});
}
}
Ok(Project {
beams,
default,
hooks,
})
}
fn beam_ref_to_id(r: &BeamRef) -> BeamId {
if r.namespace.is_empty() {
BeamId(r.name.clone())
} else {
BeamId(format!("{}:{}", r.namespace.join(":"), r.name))
}
}
fn build_beam(decl: &BeamDecl, lets: &Scope, dir: &Path) -> Result<Beam, CoreError> {
let params: Vec<String> = decl.params.iter().map(|p| p.value.clone()).collect();
let description = decl
.description
.as_ref()
.map(|t| render_load_time_template("description", t, lets, ¶ms))
.transpose()?;
let needs = decl
.needs
.iter()
.map(|n| Spanned::new(beam_ref_to_id(&n.value), n.span))
.collect();
let beam_name = decl.name.value.as_str();
let inputs = build_patterns("inputs", beam_name, &decl.inputs, lets, ¶ms)?;
let outputs = build_patterns("outputs", beam_name, &decl.outputs, lets, ¶ms)?;
for run_template in &decl.run {
validate_template(run_template, lets, ¶ms, Timing::Schedule)?;
}
for (_, env_template) in &decl.env {
validate_template(env_template, lets, ¶ms, Timing::Schedule)?;
}
let cwd = decl
.cwd
.as_ref()
.map(|t| render_load_time_template("cwd", t, lets, ¶ms))
.transpose()?;
let executor = build_executor(decl.executor.as_ref(), lets, ¶ms)?;
Ok(Beam {
id: BeamId(decl.name.value.clone()),
description,
needs,
params,
inputs,
outputs,
run: decl.run.clone(),
env: decl
.env
.iter()
.map(|(k, v)| (k.value.clone(), v.clone()))
.collect(),
cwd,
executor,
allow_failure: decl.allow_failure,
dir: dir.to_path_buf(),
span: decl.span,
source: current_source_id(),
scope: lets.clone(),
})
}
fn build_patterns(
field: &str,
beam: &str,
templates: &[StringTemplate],
lets: &Scope,
params: &[String],
) -> Result<Vec<String>, CoreError> {
let mut patterns = Vec::with_capacity(templates.len());
for template in templates {
let pattern = render_load_time_template(field, template, lets, params)?;
if let Err(error) = globset::Glob::new(&pattern) {
return Err(CoreError::new(
format!(
"invalid `{field}` pattern `{pattern}` in beam `{beam}`: {}",
error.kind()
),
template.span,
));
}
if field == "inputs" && crate::files::has_inner_dot_component(&pattern) {
return Err(CoreError::new(
format!(
"invalid `inputs` pattern `{pattern}` in beam `{beam}`: \
`.` and `..` may only start a pattern"
),
template.span,
));
}
patterns.push(pattern);
}
Ok(patterns)
}
fn build_executor(
decl: Option<&ExecutorDecl>,
lets: &Scope,
params: &[String],
) -> Result<ExecutorKind, CoreError> {
let Some(decl) = decl else {
return Ok(ExecutorKind::Shell);
};
let mut options: Vec<(String, Span, OptionValue)> = Vec::new();
for (key, value) in &decl.options {
let rendered = match value {
ExecutorOptionValue::Str(template) => OptionValue::Str(render_load_time_template(
"executor options",
template,
lets,
params,
)?),
ExecutorOptionValue::Bool(flag) => OptionValue::Bool(*flag),
ExecutorOptionValue::List(templates) => OptionValue::List(
templates
.iter()
.map(|t| render_load_time_template("executor options", t, lets, params))
.collect::<Result<_, _>>()?,
),
};
options.push((key.value.clone(), key.span, rendered));
}
match decl.name.value.as_str() {
"shell" => no_options("shell", &options, decl).map(|()| ExecutorKind::Shell),
"system_shell" => {
no_options("system_shell", &options, decl).map(|()| ExecutorKind::SystemShell)
}
"docker" => build_docker(decl, options),
other => Ok(ExecutorKind::Plugin {
name: other.to_string(),
options: options.into_iter().map(|(k, _, v)| (k, v)).collect(),
}),
}
}
fn no_options(
name: &str,
options: &[(String, Span, OptionValue)],
decl: &ExecutorDecl,
) -> Result<(), CoreError> {
if options.is_empty() {
Ok(())
} else {
Err(CoreError::new(
format!("executor `{name}` takes no options"),
decl.name.span,
))
}
}
fn build_docker(
decl: &ExecutorDecl,
options: Vec<(String, Span, OptionValue)>,
) -> Result<ExecutorKind, CoreError> {
let mut image = None;
let mut volumes = Vec::new();
let mut workdir = None;
for (key, span, value) in options {
match (key.as_str(), value) {
("image", OptionValue::Str(v)) => image = Some(v),
("image", _) => {
return Err(CoreError::new(
"executor `docker` option `image` must be a string".to_string(),
span,
));
}
("volumes", OptionValue::List(entries)) => {
for entry in &entries {
let valid = matches!(entry.rsplit_once(':'), Some((host, path))
if !host.is_empty() && path.starts_with('/'));
if !valid {
return Err(CoreError::new(
format!("invalid volume `{entry}`: expected `host:container`"),
span,
));
}
}
volumes = entries;
}
("volumes", _) => {
return Err(CoreError::new(
"executor `docker` option `volumes` must be a list of strings".to_string(),
span,
));
}
("workdir", OptionValue::Str(v)) if v.starts_with('/') => workdir = Some(v),
("workdir", _) => {
return Err(CoreError::new(
"executor `docker` option `workdir` must be an absolute container path"
.to_string(),
span,
));
}
(other, _) => {
let err = CoreError::new(
format!("executor `docker` does not take an option `{other}`"),
span,
);
return Err(
match suggest(other, ["image", "volumes", "workdir"].into_iter()) {
Some(c) => err.with_help(format!("did you mean `{c}`?")),
None => err,
},
);
}
}
}
let image = image.ok_or_else(|| {
CoreError::new(
"executor `docker` requires an `image` option".to_string(),
decl.name.span,
)
})?;
Ok(ExecutorKind::Docker {
image,
volumes,
workdir,
})
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, MutexGuard};
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvVarGuard {
name: &'static str,
previous: Option<String>,
_lock: MutexGuard<'static, ()>,
}
impl EnvVarGuard {
fn absent(name: &'static str) -> Self {
Self::hold(name, None)
}
fn set(name: &'static str, value: &str) -> Self {
Self::hold(name, Some(value))
}
fn hold(name: &'static str, value: Option<&str>) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var(name).ok();
unsafe {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
Self {
name,
previous,
_lock: lock,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(v) => unsafe { std::env::set_var(self.name, v) },
None => unsafe { std::env::remove_var(self.name) },
}
}
}
#[test]
fn evaluates_let_chain_and_interpolation() {
let _guard = EnvVarGuard::absent("ALBA_TEST_PROFILE");
let src = r#"
let profile = env("ALBA_TEST_PROFILE", "debug")
let release = profile == "release"
beam build { run "cargo build {if release then '--release' else ''}" }
"#;
let project = load_str(src).unwrap(); let beam = &project.beams[0];
let cmd = render_template(&beam.run[0], &beam.scope).unwrap();
assert_eq!(cmd, "cargo build ");
}
#[test]
fn env_reads_a_variable_that_is_set() {
let _guard = EnvVarGuard::set("ALBA_TEST_PROFILE", "release");
let src = r#"
let profile = env("ALBA_TEST_PROFILE", "debug")
beam build { run "cargo build --{profile}" }
"#;
let project = load_str(src).unwrap();
let beam = &project.beams[0];
let cmd = render_template(&beam.run[0], &beam.scope).unwrap();
assert_eq!(cmd, "cargo build --release");
}
#[test]
fn unknown_variable_reports_span_and_help() {
let err = load_str(r#"beam x { run "{profil}" }"#).unwrap_err();
assert!(err.message.contains("unknown variable"));
assert!(err.help.is_none());
}
#[test]
fn if_condition_must_be_bool() {
let err = load_str(
r#"
let x = "str"
beam b { run "{if x then 'a' else 'b'}" }
"#,
)
.unwrap_err();
assert!(err.message.contains("expected a boolean"));
}
#[test]
fn beam_parameter_shadows_a_same_named_let() {
let project = load_str(
r#"
let target = true
beam deploy(target) { run "{target + '!'}" }
"#,
)
.unwrap();
assert_eq!(project.beams[0].params, vec!["target".to_string()]);
}
#[test]
fn if_validates_the_not_taken_branch_too() {
let err = load_str(
r#"
let flag = false
beam b { run "{if flag then bogus else 'ok'}" }
"#,
)
.unwrap_err();
assert!(err.message.contains("unknown variable"));
assert!(err.message.contains("bogus"));
}
#[test]
fn load_time_field_rejects_parameter_even_when_shadowing_a_let() {
let err = load_str(
r#"
let target = "prod"
beam deploy(target) { cwd "{target}" run "echo {target}" }
"#,
)
.unwrap_err();
assert!(err.message.contains("beam parameters cannot be used"));
assert!(err.message.contains("cwd"));
}
#[test]
fn env_in_run_template_is_not_read_at_load_time() {
let project =
load_str(r#"beam deploy { run "curl -H {env('ALBA_PROBE_TOKEN_XYZ')}" }"#).unwrap();
assert_eq!(project.beams[0].id.0, "deploy");
}
#[test]
fn load_time_field_validates_the_not_taken_branch_too() {
let err = load_str(
r#"
let flag = false
beam b { cwd "{if flag then bogus else '.'}" run "echo x" }
"#,
)
.unwrap_err();
assert!(err.message.contains("unknown variable"));
assert!(err.message.contains("bogus"));
}
#[test]
fn load_time_field_rejects_an_unknown_function_in_an_untaken_branch() {
let err = load_str(
r#"
let flag = false
beam b { description "{if flag then nope('x') else 'ok'}" run "echo x" }
"#,
)
.unwrap_err();
assert!(err.message.contains("unknown function"));
}
#[test]
fn let_binding_validates_the_not_taken_branch_too() {
let err = load_str(
r#"
let flag = false
let path = if flag then bogus else "."
beam b { run "echo {path}" }
"#,
)
.unwrap_err();
assert!(err.message.contains("unknown variable"));
assert!(err.message.contains("bogus"));
}
#[test]
fn env_without_a_default_is_rejected_in_a_load_time_field() {
let err = load_str(r#"beam b { description "{env('ALBA_NOPE_XYZ')}" run "echo x" }"#)
.unwrap_err();
assert!(err.message.contains("requires a default"));
assert!(err.help.is_some());
}
#[test]
fn env_without_a_default_is_rejected_in_a_let_binding() {
let err = load_str(
r#"
let x = env("ALBA_TEST_ABSENT_XYZ")
beam b { run "x" }
"#,
)
.unwrap_err();
assert!(err.message.contains("requires a default"));
}
#[test]
fn glob_arity_is_checked_even_when_an_argument_is_unknown() {
let err = load_str(r#"beam b(p) { run "{glob(p, 'x')}" }"#).unwrap_err();
assert!(
err.message
.contains("`glob` expects 1 argument(s), found 2")
);
}
#[test]
fn glob_validates_pattern_and_returns_it_unchanged() {
let project = load_str(
r#"
let pattern = glob("src/**/*.rs")
beam b { run "{pattern}" }
"#,
)
.unwrap();
assert_eq!(
project.beams[0].scope.get("pattern"),
Some(&Value::Str("src/**/*.rs".to_string()))
);
}
#[test]
fn glob_rejects_invalid_pattern() {
let err = load_str(
r#"
let pattern = glob("[unclosed")
beam b { run "x" }
"#,
)
.unwrap_err();
assert!(err.message.contains("invalid glob pattern"));
}
#[test]
fn inputs_reject_a_pattern_that_will_not_compile() {
let err = load_str(
r#"
beam b {
inputs ["src/**/*.rs", "a[b"]
run "x"
}
"#,
)
.unwrap_err();
assert!(err.message.contains("invalid `inputs` pattern `a[b`"));
assert!(err.message.contains("beam `b`"));
}
#[test]
fn inputs_reject_a_dot_component_past_the_start() {
for pattern in ["src/../Dockerfile", "src/./a.rs", "../src/**/../x"] {
let source = format!("beam b {{\n inputs [\"{pattern}\"]\n run \"x\"\n}}\n");
let err = load_str(&source).unwrap_err();
assert!(
err.message
.contains(&format!("invalid `inputs` pattern `{pattern}`")),
"{}",
err.message
);
}
load_str(
r#"
beam b {
inputs ["../../Dockerfile", "./src/*.rs"]
run "x"
}
"#,
)
.expect("leading `.` and `..` components are anchored, not rejected");
}
#[test]
fn outputs_reject_a_pattern_that_will_not_compile() {
let err = load_str(
r#"
beam b {
outputs ["a[b"]
run "x"
}
"#,
)
.unwrap_err();
assert!(err.message.contains("invalid `outputs` pattern `a[b`"));
}
#[test]
fn the_glob_builtin_and_the_inputs_field_keep_their_own_dialects() {
let err = load_str(
r#"
let pattern = glob("src**/x.rs")
beam b { run "{pattern}" }
"#,
)
.unwrap_err();
assert!(err.message.contains("invalid glob pattern `src**/x.rs`"));
load_str(
r#"
beam b {
inputs ["src**/x.rs"]
run "x"
}
"#,
)
.expect("`globset` accepts this, and `globset` is what matches `inputs`");
}
#[test]
fn binary_operators_evaluate_correctly() {
let project = load_str(
r#"
let a = true
let b = false
let and_result = a && b
let or_result = a || b
let neq_result = a != b
let concat_result = "foo" + "bar"
beam x { run "ok" }
"#,
)
.unwrap();
let scope = &project.beams[0].scope;
assert_eq!(scope.get("and_result"), Some(&Value::Bool(false)));
assert_eq!(scope.get("or_result"), Some(&Value::Bool(true)));
assert_eq!(scope.get("neq_result"), Some(&Value::Bool(true)));
assert_eq!(
scope.get("concat_result"),
Some(&Value::Str("foobar".to_string()))
);
}
#[test]
fn unknown_function_errors_with_help() {
let err = load_str(
r#"
let x = evn("Y")
beam b { run "x" }
"#,
)
.unwrap_err();
assert!(err.message.contains("unknown function"));
assert_eq!(err.help.as_deref(), Some("did you mean `env`?"));
}
#[test]
fn executor_docker_carries_image() {
let project =
load_str(r#"beam deploy { executor docker { image "registry/app:latest" } run "x" }"#)
.unwrap();
assert_eq!(
project.beams[0].executor,
ExecutorKind::Docker {
image: "registry/app:latest".to_string(),
volumes: Vec::new(),
workdir: None,
}
);
}
#[test]
fn executor_docker_carries_volumes_and_workdir() {
let project = load_str(
r#"beam d { executor docker { image "x" volumes ["h:/c"] workdir "/w" } run "x" }"#,
)
.unwrap();
assert_eq!(
project.beams[0].executor,
ExecutorKind::Docker {
image: "x".into(),
volumes: vec!["h:/c".into()],
workdir: Some("/w".into()),
}
);
}
#[test]
fn docker_volumes_must_be_a_list() {
let err = load_str(r#"beam d { executor docker { image "x" volumes "h:/c" } run "x" }"#)
.unwrap_err();
assert!(
err.message
.contains("executor `docker` option `volumes` must be a list of strings")
);
}
#[test]
fn docker_volume_entries_must_name_a_container_path() {
let err =
load_str(r#"beam d { executor docker { image "x" volumes ["nocolon"] } run "x" }"#)
.unwrap_err();
assert!(
err.message
.contains("invalid volume `nocolon`: expected `host:container`")
);
}
#[test]
fn docker_workdir_must_be_an_absolute_container_path() {
let err =
load_str(r#"beam d { executor docker { image "x" workdir "relative" } run "x" }"#)
.unwrap_err();
assert!(
err.message
.contains("executor `docker` option `workdir` must be an absolute container path")
);
}
#[test]
fn docker_rejects_an_unknown_option_with_a_suggestion() {
let err = load_str(r#"beam d { executor docker { image "x" volume ["a:/b"] } run "x" }"#)
.unwrap_err();
assert!(
err.message
.contains("executor `docker` does not take an option `volume`")
);
assert_eq!(err.help.as_deref(), Some("did you mean `volumes`?"));
}
#[test]
fn an_unknown_executor_becomes_a_plugin_reference() {
let project = load_str(
r#"beam d { executor podman { image "x" remote true tags ["a", "b"] } run "x" }"#,
)
.unwrap();
assert_eq!(
project.beams[0].executor,
ExecutorKind::Plugin {
name: "podman".into(),
options: vec![
("image".into(), OptionValue::Str("x".into())),
("remote".into(), OptionValue::Bool(true)),
(
"tags".into(),
OptionValue::List(vec!["a".into(), "b".into()])
),
],
}
);
}
#[test]
fn shell_takes_no_options() {
let err = load_str(r#"beam b { executor shell { image "x" } run "y" }"#).unwrap_err();
assert!(err.message.contains("executor `shell` takes no options"));
}
#[test]
fn an_executor_name_near_docker_still_becomes_a_plugin_reference() {
let project = load_str(r#"beam deploy { executor dokcer { image "x" } run "y" }"#).unwrap();
assert_eq!(
project.beams[0].executor,
ExecutorKind::Plugin {
name: "dokcer".to_string(),
options: vec![("image".to_string(), OptionValue::Str("x".to_string()))],
}
);
}
#[test]
fn system_shell_executor_maps_to_its_kind() {
let project = load_str("beam b {\n executor system_shell\n run \"x\"\n}").unwrap();
assert_eq!(project.beams[0].executor, ExecutorKind::SystemShell);
}
#[test]
fn system_shell_rejects_options() {
let err = load_str("beam b {\n executor system_shell { image \"i\" }\n run \"x\"\n}")
.unwrap_err();
assert!(
err.message
.contains("executor `system_shell` takes no options")
);
}
#[test]
fn an_executor_name_near_system_shell_still_becomes_a_plugin_reference() {
let project = load_str("beam b {\n executor system_shel\n run \"x\"\n}").unwrap();
assert_eq!(
project.beams[0].executor,
ExecutorKind::Plugin {
name: "system_shel".to_string(),
options: Vec::new(),
}
);
}
#[test]
fn unknown_variable_suggests_close_match() {
let err = load_str(
r#"
let release = true
beam b { run "{relase}" }
"#,
)
.unwrap_err();
assert_eq!(err.help.as_deref(), Some("did you mean `release`?"));
}
#[test]
fn suggest_breaks_distance_ties_alphabetically() {
assert_eq!(suggest("hat", ["cat", "bat"].into_iter()), Some("bat"));
assert_eq!(suggest("hat", ["bat", "cat"].into_iter()), Some("bat"));
}
#[test]
fn load_str_rejects_imports() {
let err = load_str("import \"x/Beamfile\" as x\nbeam b { run \"echo\" }").unwrap_err();
assert!(err.message.contains("imports are not supported"));
}
}