1use std::collections::HashMap;
65use std::path::{Path, PathBuf};
66use std::sync::{Arc, OnceLock};
67
68use alba_syntax::{
69 BeamDecl, BeamRef, BinOp, ExecutorDecl, ExecutorOptionValue, Expr, File, ParseError, Span,
70 Spanned, StringTemplate, TemplatePart,
71};
72
73use crate::error::{CoreError, ROOT_SOURCE_ID, SourceIdScope, current_source_id};
74use crate::git::{GitError, GitHead};
75use crate::model::{Beam, BeamId, ExecutorKind, Hook, OptionValue, Project, Value};
76
77const BUILTIN_FUNCTIONS: &[&str] = &["env", "glob"];
79
80#[derive(Debug, Clone, Default, PartialEq)]
88pub struct Scope {
89 values: HashMap<String, Value>,
90 git: Option<Arc<LazyGit>>,
91}
92
93#[derive(Debug)]
97pub(crate) struct LazyGit {
98 root: PathBuf,
99 head: OnceLock<Result<GitHead, GitError>>,
100}
101
102impl LazyGit {
103 pub(crate) fn new(root: PathBuf) -> Self {
104 Self {
105 root,
106 head: OnceLock::new(),
107 }
108 }
109
110 fn head(&self) -> Result<&GitHead, GitError> {
111 self.head
112 .get_or_init(|| crate::git::head(&self.root))
113 .as_ref()
114 .map_err(Clone::clone)
115 }
116}
117
118impl PartialEq for LazyGit {
121 fn eq(&self, other: &Self) -> bool {
122 self.root == other.root
123 }
124}
125
126impl Scope {
127 pub fn empty() -> Self {
129 Self::default()
130 }
131
132 pub(crate) fn with_git(git: Arc<LazyGit>) -> Self {
135 Self {
136 values: HashMap::new(),
137 git: Some(git),
138 }
139 }
140
141 fn git_field(&self, field: &Spanned<String>, span: Span) -> Result<Value, CoreError> {
147 let Some(git) = &self.git else {
148 return Err(CoreError::new("`git` is not available here", span));
149 };
150 let head = git.head().map_err(|error| {
151 CoreError::new(format!("cannot read `git.{}`: {error}", field.value), span)
152 })?;
153 Ok(match field.value.as_str() {
154 "branch" => Value::Str(head.branch.clone()),
155 "sha" => Value::Str(head.sha.clone()),
156 "short_sha" => Value::Str(head.short_sha.clone()),
157 "dirty" => Value::Bool(head.dirty),
158 _ => unreachable!("checked by check_field before this method is ever called"),
159 })
160 }
161
162 pub(crate) fn insert(&mut self, name: String, value: Value) {
166 self.values.insert(name, value);
167 }
168
169 pub fn get(&self, name: &str) -> Option<&Value> {
170 self.values.get(name)
171 }
172
173 pub fn names(&self) -> impl Iterator<Item = &str> {
176 self.values.keys().map(String::as_str)
177 }
178
179 pub fn with_params(&self, params: &[String], args: &[String]) -> Self {
195 let mut values = self.values.clone();
196 for (name, arg) in params.iter().zip(args.iter()) {
197 values.insert(name.clone(), Value::Str(arg.clone()));
198 }
199 Self {
200 values,
201 git: self.git.clone(),
202 }
203 }
204}
205
206fn expr_span(expr: &Expr) -> Option<Span> {
212 match expr {
213 Expr::Str(t) => Some(t.span),
214 Expr::Var(name) => Some(name.span),
215 Expr::Call { name, .. } => Some(name.span),
216 Expr::Bool(_) | Expr::Binary { .. } | Expr::If { .. } => None,
217 Expr::Field { object, field } => Some(Span::new(object.span.start, field.span.end)),
218 }
219}
220
221fn check_field(object: &Spanned<String>, field: &Spanned<String>) -> Result<(), CoreError> {
227 if object.value != "git" {
228 return Err(CoreError::new(
229 format!("`{}` has no fields; only `git` does", object.value),
230 object.span,
231 ));
232 }
233 if !matches!(
234 field.value.as_str(),
235 "branch" | "sha" | "short_sha" | "dirty"
236 ) {
237 return Err(
238 CoreError::new(format!("unknown git field `{}`", field.value), field.span)
239 .with_help("expected one of `branch`, `sha`, `short_sha`, `dirty`"),
240 );
241 }
242 Ok(())
243}
244
245fn eval_field(
248 object: &Spanned<String>,
249 field: &Spanned<String>,
250 scope: &Scope,
251) -> Result<Value, CoreError> {
252 check_field(object, field)?;
253 scope.git_field(field, Span::new(object.span.start, field.span.end))
254}
255
256fn levenshtein(a: &str, b: &str) -> usize {
264 let a: Vec<char> = a.chars().collect();
265 let b: Vec<char> = b.chars().collect();
266
267 let mut row: Vec<usize> = (0..=b.len()).collect();
268 for i in 1..=a.len() {
269 let mut prev_diag = row[0];
270 row[0] = i;
271 for j in 1..=b.len() {
272 let temp = row[j];
273 row[j] = if a[i - 1] == b[j - 1] {
274 prev_diag
275 } else {
276 1 + prev_diag.min(row[j]).min(row[j - 1])
277 };
278 prev_diag = temp;
279 }
280 }
281 row[b.len()]
282}
283
284pub fn suggest<'a>(name: &str, candidates: impl Iterator<Item = &'a str>) -> Option<&'a str> {
299 let mut scored: Vec<(&str, usize)> = candidates
300 .map(|candidate| (candidate, levenshtein(name, candidate)))
301 .filter(|&(_, distance)| distance <= 2)
302 .collect();
303 scored.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0)));
304 scored.into_iter().next().map(|(candidate, _)| candidate)
305}
306
307fn unknown_variable_error<'a>(
308 name: &Spanned<String>,
309 candidates: impl Iterator<Item = &'a str>,
310) -> CoreError {
311 let err = CoreError::new(format!("unknown variable `{}`", name.value), name.span);
312 match suggest(&name.value, candidates) {
313 Some(c) => err.with_help(format!("did you mean `{c}`?")),
314 None => err,
315 }
316}
317
318fn unknown_function_error(name: &Spanned<String>) -> CoreError {
319 let err = CoreError::new(format!("unknown function `{}`", name.value), name.span);
320 match suggest(&name.value, BUILTIN_FUNCTIONS.iter().copied()) {
321 Some(c) => err.with_help(format!("did you mean `{c}`?")),
322 None => err,
323 }
324}
325
326pub fn render_template(template: &StringTemplate, scope: &Scope) -> Result<String, CoreError> {
330 let mut out = String::new();
331 for part in &template.parts {
332 match part {
333 TemplatePart::Literal(s) => out.push_str(s),
334 TemplatePart::Expr(e) => {
335 let fallback = expr_span(e).unwrap_or(template.span);
336 let value = eval_with_fallback(e, scope, fallback)?;
337 out.push_str(value.as_display());
338 }
339 }
340 }
341 Ok(out)
342}
343
344pub fn eval_expr(expr: &Expr, scope: &Scope) -> Result<Value, CoreError> {
353 let fallback = expr_span(expr).unwrap_or(Span::new(0, 0));
354 eval_with_fallback(expr, scope, fallback)
355}
356
357fn eval_with_fallback(expr: &Expr, scope: &Scope, fallback: Span) -> Result<Value, CoreError> {
358 match expr {
359 Expr::Str(t) => Ok(Value::Str(render_template(t, scope)?)),
360 Expr::Bool(b) => Ok(Value::Bool(*b)),
361 Expr::Var(name) => scope
362 .get(&name.value)
363 .cloned()
364 .ok_or_else(|| unknown_variable_error(name, scope.names())),
365 Expr::Call { name, args } => eval_call(name, args, scope, fallback),
366 Expr::Binary { op, lhs, rhs } => {
367 let lv = eval_with_fallback(lhs, scope, expr_span(lhs).unwrap_or(fallback))?;
368 let rv = eval_with_fallback(rhs, scope, expr_span(rhs).unwrap_or(fallback))?;
369 eval_binary(*op, lv, rv, fallback)
370 }
371 Expr::If {
372 cond,
373 then,
374 otherwise,
375 } => {
376 let cond_span = expr_span(cond).unwrap_or(fallback);
377 match eval_with_fallback(cond, scope, cond_span)? {
378 Value::Bool(true) => {
379 eval_with_fallback(then, scope, expr_span(then).unwrap_or(fallback))
380 }
381 Value::Bool(false) => {
382 eval_with_fallback(otherwise, scope, expr_span(otherwise).unwrap_or(fallback))
383 }
384 other => Err(CoreError::new(
385 format!(
386 "expected a boolean condition for `if`, found {}",
387 other.type_name()
388 ),
389 cond_span,
390 )),
391 }
392 }
393 Expr::Field { object, field } => eval_field(object, field, scope),
394 }
395}
396
397fn eval_binary(op: BinOp, lhs: Value, rhs: Value, span: Span) -> Result<Value, CoreError> {
398 match op {
399 BinOp::Concat => match (lhs, rhs) {
400 (Value::Str(a), Value::Str(b)) => Ok(Value::Str(a + &b)),
401 (l, r) => Err(CoreError::new(
402 format!(
403 "`+` requires two strings, found {} and {}",
404 l.type_name(),
405 r.type_name()
406 ),
407 span,
408 )),
409 },
410 BinOp::Eq | BinOp::NotEq => {
411 if std::mem::discriminant(&lhs) != std::mem::discriminant(&rhs) {
412 return Err(CoreError::new(
413 format!(
414 "`==`/`!=` requires operands of the same type, found {} and {}",
415 lhs.type_name(),
416 rhs.type_name()
417 ),
418 span,
419 ));
420 }
421 let equal = lhs == rhs;
422 Ok(Value::Bool(if op == BinOp::Eq { equal } else { !equal }))
423 }
424 BinOp::And | BinOp::Or => match (lhs, rhs) {
425 (Value::Bool(a), Value::Bool(b)) => {
426 Ok(Value::Bool(if op == BinOp::And { a && b } else { a || b }))
427 }
428 (l, r) => Err(CoreError::new(
429 format!(
430 "`&&`/`||` requires two booleans, found {} and {}",
431 l.type_name(),
432 r.type_name()
433 ),
434 span,
435 )),
436 },
437 }
438}
439
440fn eval_call(
441 name: &Spanned<String>,
442 args: &[Expr],
443 scope: &Scope,
444 fallback: Span,
445) -> Result<Value, CoreError> {
446 match name.value.as_str() {
447 "env" => eval_env(name, args, scope, fallback),
448 "glob" => eval_glob(name, args, scope, fallback),
449 _ => Err(unknown_function_error(name)),
450 }
451}
452
453fn arity_error(name: &Spanned<String>, min: usize, max: usize, found: usize) -> CoreError {
454 let expected = if min == max {
455 format!("{min}")
456 } else {
457 format!("{min} to {max}")
458 };
459 CoreError::new(
460 format!(
461 "`{}` expects {expected} argument(s), found {found}",
462 name.value
463 ),
464 name.span,
465 )
466}
467
468fn expect_str_value(fn_name: &str, value: Value, span: Span) -> Result<String, CoreError> {
475 match value {
476 Value::Str(s) => Ok(s),
477 other => Err(CoreError::new(
478 format!(
479 "argument to `{fn_name}` must be a string, found {}",
480 other.type_name()
481 ),
482 span,
483 )),
484 }
485}
486
487fn expect_str_arg(
489 fn_name: &Spanned<String>,
490 arg: &Expr,
491 scope: &Scope,
492 fallback: Span,
493) -> Result<String, CoreError> {
494 let span = expr_span(arg).unwrap_or(fallback);
495 let value = eval_with_fallback(arg, scope, span)?;
496 expect_str_value(&fn_name.value, value, span)
497}
498
499fn eval_env(
501 name: &Spanned<String>,
502 args: &[Expr],
503 scope: &Scope,
504 fallback: Span,
505) -> Result<Value, CoreError> {
506 if args.is_empty() || args.len() > 2 {
507 return Err(arity_error(name, 1, 2, args.len()));
508 }
509 let key = expect_str_arg(name, &args[0], scope, fallback)?;
510 match std::env::var(&key) {
511 Ok(v) => Ok(Value::Str(v)),
512 Err(_) => match args.get(1) {
513 Some(default_expr) => {
514 let span = expr_span(default_expr).unwrap_or(fallback);
515 eval_with_fallback(default_expr, scope, span)
516 }
517 None => Err(CoreError::new(
518 format!("environment variable `{key}` is not set"),
519 name.span,
520 )),
521 },
522 }
523}
524
525fn validate_glob_pattern(pattern: String, span: Span) -> Result<Value, CoreError> {
529 glob::Pattern::new(&pattern)
530 .map_err(|e| CoreError::new(format!("invalid glob pattern `{pattern}`: {e}"), span))?;
531 Ok(Value::Str(pattern))
532}
533
534fn eval_glob(
537 name: &Spanned<String>,
538 args: &[Expr],
539 scope: &Scope,
540 fallback: Span,
541) -> Result<Value, CoreError> {
542 if args.len() != 1 {
543 return Err(arity_error(name, 1, 1, args.len()));
544 }
545 let pattern_span = expr_span(&args[0]).unwrap_or(fallback);
546 let pattern = expect_str_arg(name, &args[0], scope, fallback)?;
547 validate_glob_pattern(pattern, pattern_span)
548}
549
550enum StaticValue {
555 Known(Value),
556 Unknown,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564enum Timing {
565 Load,
568 Schedule,
571}
572
573fn check_expr(
580 expr: &Expr,
581 lets: &Scope,
582 params: &[String],
583 timing: Timing,
584 fallback: Span,
585) -> Result<StaticValue, CoreError> {
586 match expr {
587 Expr::Bool(b) => Ok(StaticValue::Known(Value::Bool(*b))),
588 Expr::Var(name) => {
589 if params.iter().any(|p| p == &name.value) {
595 Ok(StaticValue::Unknown)
596 } else if let Some(v) = lets.get(&name.value) {
597 Ok(StaticValue::Known(v.clone()))
598 } else {
599 let candidates = lets.names().chain(params.iter().map(String::as_str));
600 Err(unknown_variable_error(name, candidates))
601 }
602 }
603 Expr::Str(template) => {
604 let mut any_unknown = false;
610 let mut rendered = String::new();
611 for part in &template.parts {
612 match part {
613 TemplatePart::Literal(s) => rendered.push_str(s),
614 TemplatePart::Expr(e) => {
615 let span = expr_span(e).unwrap_or(template.span);
616 match check_expr(e, lets, params, timing, span)? {
617 StaticValue::Known(v) => rendered.push_str(v.as_display()),
618 StaticValue::Unknown => any_unknown = true,
619 }
620 }
621 }
622 }
623 if any_unknown {
624 Ok(StaticValue::Unknown)
625 } else {
626 Ok(StaticValue::Known(Value::Str(rendered)))
627 }
628 }
629 Expr::Call { name, args } => match name.value.as_str() {
633 "env" => check_env_call(name, args, lets, params, timing, fallback),
634 "glob" => check_glob_call(name, args, lets, params, timing, fallback),
635 _ => Err(unknown_function_error(name)),
636 },
637 Expr::Binary { op, lhs, rhs } => {
638 let l = check_expr(
639 lhs,
640 lets,
641 params,
642 timing,
643 expr_span(lhs).unwrap_or(fallback),
644 )?;
645 let r = check_expr(
646 rhs,
647 lets,
648 params,
649 timing,
650 expr_span(rhs).unwrap_or(fallback),
651 )?;
652 match (l, r) {
653 (StaticValue::Known(lv), StaticValue::Known(rv)) => {
654 Ok(StaticValue::Known(eval_binary(*op, lv, rv, fallback)?))
655 }
656 _ => Ok(StaticValue::Unknown),
660 }
661 }
662 Expr::If {
663 cond,
664 then,
665 otherwise,
666 } => {
667 let cond_span = expr_span(cond).unwrap_or(fallback);
668 let then_span = expr_span(then).unwrap_or(fallback);
669 let otherwise_span = expr_span(otherwise).unwrap_or(fallback);
670 match check_expr(cond, lets, params, timing, cond_span)? {
671 StaticValue::Known(Value::Bool(cond_value)) => {
672 let then_result = check_expr(then, lets, params, timing, then_span)?;
680 let else_result = check_expr(otherwise, lets, params, timing, otherwise_span)?;
681 Ok(if cond_value { then_result } else { else_result })
682 }
683 StaticValue::Known(other) => Err(CoreError::new(
684 format!(
685 "expected a boolean condition for `if`, found {}",
686 other.type_name()
687 ),
688 cond_span,
689 )),
690 StaticValue::Unknown => {
691 check_expr(then, lets, params, timing, then_span)?;
694 check_expr(otherwise, lets, params, timing, otherwise_span)?;
695 Ok(StaticValue::Unknown)
696 }
697 }
698 }
699 Expr::Field { object, field } => {
708 check_field(object, field)?;
709 Ok(StaticValue::Unknown)
710 }
711 }
712}
713
714fn check_env_call(
718 name: &Spanned<String>,
719 args: &[Expr],
720 lets: &Scope,
721 params: &[String],
722 timing: Timing,
723 fallback: Span,
724) -> Result<StaticValue, CoreError> {
725 if args.is_empty() || args.len() > 2 {
726 return Err(arity_error(name, 1, 2, args.len()));
727 }
728 for arg in args {
729 let span = expr_span(arg).unwrap_or(fallback);
730 check_expr(arg, lets, params, timing, span)?;
731 }
732 if timing == Timing::Load && args.len() == 1 {
733 return Err(CoreError::new(
734 "`env` requires a default value here",
735 name.span,
736 )
737 .with_help(
738 "add one, e.g. `env(\"NAME\", \"fallback\")`, or read the variable from `run`/`env` \
739 instead, where it resolves when the beam runs",
740 ));
741 }
742 Ok(StaticValue::Unknown)
746}
747
748fn check_glob_call(
752 name: &Spanned<String>,
753 args: &[Expr],
754 lets: &Scope,
755 params: &[String],
756 timing: Timing,
757 fallback: Span,
758) -> Result<StaticValue, CoreError> {
759 if args.len() != 1 {
760 return Err(arity_error(name, 1, 1, args.len()));
761 }
762 let pattern_span = expr_span(&args[0]).unwrap_or(fallback);
763 match check_expr(&args[0], lets, params, timing, pattern_span)? {
764 StaticValue::Known(value) => {
765 let pattern = expect_str_value(&name.value, value, pattern_span)?;
766 Ok(StaticValue::Known(validate_glob_pattern(
767 pattern,
768 pattern_span,
769 )?))
770 }
771 StaticValue::Unknown => Ok(StaticValue::Unknown),
772 }
773}
774
775fn validate_template(
781 template: &StringTemplate,
782 lets: &Scope,
783 params: &[String],
784 timing: Timing,
785) -> Result<(), CoreError> {
786 for part in &template.parts {
787 if let TemplatePart::Expr(e) = part {
788 let span = expr_span(e).unwrap_or(template.span);
789 check_expr(e, lets, params, timing, span)?;
790 }
791 }
792 Ok(())
793}
794
795fn reject_param_references(expr: &Expr, params: &[String], field: &str) -> Result<(), CoreError> {
804 match expr {
805 Expr::Bool(_) => Ok(()),
806 Expr::Var(name) => {
807 if params.iter().any(|p| p == &name.value) {
808 Err(CoreError::new(
809 format!("beam parameters cannot be used in `{field}`"),
810 name.span,
811 ))
812 } else {
813 Ok(())
814 }
815 }
816 Expr::Str(template) => reject_param_references_in_template(template, params, field),
817 Expr::Call { args, .. } => args
818 .iter()
819 .try_for_each(|arg| reject_param_references(arg, params, field)),
820 Expr::Binary { lhs, rhs, .. } => {
821 reject_param_references(lhs, params, field)?;
822 reject_param_references(rhs, params, field)
823 }
824 Expr::If {
825 cond,
826 then,
827 otherwise,
828 } => {
829 reject_param_references(cond, params, field)?;
830 reject_param_references(then, params, field)?;
831 reject_param_references(otherwise, params, field)
832 }
833 Expr::Field { .. } => Ok(()),
834 }
835}
836
837fn reject_param_references_in_template(
838 template: &StringTemplate,
839 params: &[String],
840 field: &str,
841) -> Result<(), CoreError> {
842 template.parts.iter().try_for_each(|part| match part {
843 TemplatePart::Literal(_) => Ok(()),
844 TemplatePart::Expr(e) => reject_param_references(e, params, field),
845 })
846}
847
848fn render_load_time_template(
858 field: &str,
859 template: &StringTemplate,
860 lets: &Scope,
861 params: &[String],
862) -> Result<String, CoreError> {
863 reject_param_references_in_template(template, params, field)?;
864 validate_template(template, lets, &[], Timing::Load)?;
865 render_template(template, lets)
866}
867
868pub(crate) fn parse_error_to_core_error(e: ParseError) -> CoreError {
874 let err = CoreError::new(e.message, e.span);
875 match e.help {
876 Some(help) => err.with_help(help),
877 None => err,
878 }
879}
880
881#[doc(hidden)]
914pub fn load_str(source: &str) -> Result<Project, CoreError> {
915 let _scope = SourceIdScope::enter(ROOT_SOURCE_ID);
916 let file = alba_syntax::parse(source).map_err(parse_error_to_core_error)?;
917 if let Some(import) = file.imports.first() {
918 return Err(CoreError::new(
919 "imports are not supported by `load_str`; use `load_project` instead",
920 import.path.span,
921 ));
922 }
923 let project = build_project(
924 &file,
925 Path::new("."),
926 Arc::new(LazyGit::new(PathBuf::from("."))),
927 true,
928 )?;
929 crate::graph::validate_graph(&project)?;
930 Ok(project)
931}
932
933pub(crate) fn build_project(
940 file: &File,
941 dir: &Path,
942 git: Arc<LazyGit>,
943 is_root: bool,
944) -> Result<Project, CoreError> {
945 let mut lets = Scope::with_git(git);
946 for binding in &file.lets {
947 let fallback = expr_span(&binding.value).unwrap_or(binding.name.span);
952 check_expr(&binding.value, &lets, &[], Timing::Load, fallback)?;
953 let value = eval_expr(&binding.value, &lets)?;
954 lets.insert(binding.name.value.clone(), value);
955 }
956
957 let beams = file
958 .beams
959 .iter()
960 .map(|decl| build_beam(decl, &lets, dir))
961 .collect::<Result<Vec<_>, _>>()?;
962
963 let default = file
964 .default
965 .as_ref()
966 .map(|d| Spanned::new(BeamId(d.value.clone()), d.span));
967
968 let mut hooks: Vec<Hook> = Vec::new();
974 if is_root {
975 for decl in &file.hooks {
976 let Some(known) = crate::git::known_hook(&decl.name.value) else {
977 let mut err = CoreError::new(
978 format!("unknown git hook `{}`", decl.name.value),
979 decl.name.span,
980 );
981 if let Some(candidate) = suggest(
982 &decl.name.value,
983 crate::git::KNOWN_HOOKS.iter().map(|hook| hook.name),
984 ) {
985 err = err.with_help(format!("did you mean `{candidate}`?"));
986 }
987 return Err(err);
988 };
989 if hooks.iter().any(|hook| hook.name == decl.name.value) {
990 return Err(CoreError::new(
991 format!("duplicate hook `{}`", decl.name.value),
992 decl.name.span,
993 ));
994 }
995 hooks.push(Hook {
996 name: decl.name.value.clone(),
997 beam: Spanned::new(beam_ref_to_id(&decl.beam.value), decl.beam.span),
998 arity: known.arity,
999 span: decl.span,
1000 source: current_source_id(),
1001 });
1002 }
1003 }
1004
1005 Ok(Project {
1006 beams,
1007 default,
1008 hooks,
1009 })
1010}
1011
1012fn beam_ref_to_id(r: &BeamRef) -> BeamId {
1013 if r.namespace.is_empty() {
1014 BeamId(r.name.clone())
1015 } else {
1016 BeamId(format!("{}:{}", r.namespace.join(":"), r.name))
1017 }
1018}
1019
1020fn build_beam(decl: &BeamDecl, lets: &Scope, dir: &Path) -> Result<Beam, CoreError> {
1021 let params: Vec<String> = decl.params.iter().map(|p| p.value.clone()).collect();
1022
1023 let description = decl
1024 .description
1025 .as_ref()
1026 .map(|t| render_load_time_template("description", t, lets, ¶ms))
1027 .transpose()?;
1028
1029 let needs = decl
1030 .needs
1031 .iter()
1032 .map(|n| Spanned::new(beam_ref_to_id(&n.value), n.span))
1033 .collect();
1034
1035 let beam_name = decl.name.value.as_str();
1036 let inputs = build_patterns("inputs", beam_name, &decl.inputs, lets, ¶ms)?;
1037 let outputs = build_patterns("outputs", beam_name, &decl.outputs, lets, ¶ms)?;
1038
1039 for run_template in &decl.run {
1040 validate_template(run_template, lets, ¶ms, Timing::Schedule)?;
1041 }
1042 for (_, env_template) in &decl.env {
1043 validate_template(env_template, lets, ¶ms, Timing::Schedule)?;
1044 }
1045
1046 let cwd = decl
1047 .cwd
1048 .as_ref()
1049 .map(|t| render_load_time_template("cwd", t, lets, ¶ms))
1050 .transpose()?;
1051 let executor = build_executor(decl.executor.as_ref(), lets, ¶ms)?;
1052
1053 Ok(Beam {
1054 id: BeamId(decl.name.value.clone()),
1055 description,
1056 needs,
1057 params,
1058 inputs,
1059 outputs,
1060 run: decl.run.clone(),
1061 env: decl
1062 .env
1063 .iter()
1064 .map(|(k, v)| (k.value.clone(), v.clone()))
1065 .collect(),
1066 cwd,
1067 executor,
1068 allow_failure: decl.allow_failure,
1069 dir: dir.to_path_buf(),
1070 span: decl.span,
1071 source: current_source_id(),
1072 scope: lets.clone(),
1073 })
1074}
1075
1076fn build_patterns(
1089 field: &str,
1090 beam: &str,
1091 templates: &[StringTemplate],
1092 lets: &Scope,
1093 params: &[String],
1094) -> Result<Vec<String>, CoreError> {
1095 let mut patterns = Vec::with_capacity(templates.len());
1096 for template in templates {
1097 let pattern = render_load_time_template(field, template, lets, params)?;
1098 if let Err(error) = globset::Glob::new(&pattern) {
1099 return Err(CoreError::new(
1100 format!(
1101 "invalid `{field}` pattern `{pattern}` in beam `{beam}`: {}",
1102 error.kind()
1103 ),
1104 template.span,
1105 ));
1106 }
1107 patterns.push(pattern);
1108 }
1109 Ok(patterns)
1110}
1111
1112fn build_executor(
1113 decl: Option<&ExecutorDecl>,
1114 lets: &Scope,
1115 params: &[String],
1116) -> Result<ExecutorKind, CoreError> {
1117 let Some(decl) = decl else {
1118 return Ok(ExecutorKind::Shell);
1119 };
1120
1121 let mut options: Vec<(String, Span, OptionValue)> = Vec::new();
1124 for (key, value) in &decl.options {
1125 let rendered = match value {
1126 ExecutorOptionValue::Str(template) => OptionValue::Str(render_load_time_template(
1127 "executor options",
1128 template,
1129 lets,
1130 params,
1131 )?),
1132 ExecutorOptionValue::Bool(flag) => OptionValue::Bool(*flag),
1133 ExecutorOptionValue::List(templates) => OptionValue::List(
1134 templates
1135 .iter()
1136 .map(|t| render_load_time_template("executor options", t, lets, params))
1137 .collect::<Result<_, _>>()?,
1138 ),
1139 };
1140 options.push((key.value.clone(), key.span, rendered));
1141 }
1142
1143 match decl.name.value.as_str() {
1144 "shell" => no_options("shell", &options, decl).map(|()| ExecutorKind::Shell),
1145 "system_shell" => {
1146 no_options("system_shell", &options, decl).map(|()| ExecutorKind::SystemShell)
1147 }
1148 "docker" => build_docker(decl, options),
1149 other => Ok(ExecutorKind::Plugin {
1150 name: other.to_string(),
1151 options: options.into_iter().map(|(k, _, v)| (k, v)).collect(),
1152 }),
1153 }
1154}
1155
1156fn no_options(
1159 name: &str,
1160 options: &[(String, Span, OptionValue)],
1161 decl: &ExecutorDecl,
1162) -> Result<(), CoreError> {
1163 if options.is_empty() {
1164 Ok(())
1165 } else {
1166 Err(CoreError::new(
1167 format!("executor `{name}` takes no options"),
1168 decl.name.span,
1169 ))
1170 }
1171}
1172
1173fn build_docker(
1177 decl: &ExecutorDecl,
1178 options: Vec<(String, Span, OptionValue)>,
1179) -> Result<ExecutorKind, CoreError> {
1180 let mut image = None;
1181 let mut volumes = Vec::new();
1182 let mut workdir = None;
1183 for (key, span, value) in options {
1184 match (key.as_str(), value) {
1185 ("image", OptionValue::Str(v)) => image = Some(v),
1186 ("image", _) => {
1187 return Err(CoreError::new(
1188 "executor `docker` option `image` must be a string".to_string(),
1189 span,
1190 ));
1191 }
1192 ("volumes", OptionValue::List(entries)) => {
1193 for entry in &entries {
1194 let valid = matches!(entry.rsplit_once(':'), Some((host, path))
1197 if !host.is_empty() && path.starts_with('/'));
1198 if !valid {
1199 return Err(CoreError::new(
1200 format!("invalid volume `{entry}`: expected `host:container`"),
1201 span,
1202 ));
1203 }
1204 }
1205 volumes = entries;
1206 }
1207 ("volumes", _) => {
1208 return Err(CoreError::new(
1209 "executor `docker` option `volumes` must be a list of strings".to_string(),
1210 span,
1211 ));
1212 }
1213 ("workdir", OptionValue::Str(v)) if v.starts_with('/') => workdir = Some(v),
1214 ("workdir", _) => {
1215 return Err(CoreError::new(
1216 "executor `docker` option `workdir` must be an absolute container path"
1217 .to_string(),
1218 span,
1219 ));
1220 }
1221 (other, _) => {
1222 let err = CoreError::new(
1223 format!("executor `docker` does not take an option `{other}`"),
1224 span,
1225 );
1226 return Err(
1227 match suggest(other, ["image", "volumes", "workdir"].into_iter()) {
1228 Some(c) => err.with_help(format!("did you mean `{c}`?")),
1229 None => err,
1230 },
1231 );
1232 }
1233 }
1234 }
1235 let image = image.ok_or_else(|| {
1236 CoreError::new(
1237 "executor `docker` requires an `image` option".to_string(),
1238 decl.name.span,
1239 )
1240 })?;
1241 Ok(ExecutorKind::Docker {
1242 image,
1243 volumes,
1244 workdir,
1245 })
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use std::sync::{Mutex, MutexGuard};
1251
1252 use super::*;
1253
1254 static ENV_LOCK: Mutex<()> = Mutex::new(());
1262
1263 struct EnvVarGuard {
1269 name: &'static str,
1270 previous: Option<String>,
1271 _lock: MutexGuard<'static, ()>,
1275 }
1276
1277 impl EnvVarGuard {
1278 fn absent(name: &'static str) -> Self {
1280 Self::hold(name, None)
1281 }
1282
1283 fn set(name: &'static str, value: &str) -> Self {
1285 Self::hold(name, Some(value))
1286 }
1287
1288 fn hold(name: &'static str, value: Option<&str>) -> Self {
1289 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1295 let previous = std::env::var(name).ok();
1296 unsafe {
1310 match value {
1311 Some(value) => std::env::set_var(name, value),
1312 None => std::env::remove_var(name),
1313 }
1314 }
1315 Self {
1316 name,
1317 previous,
1318 _lock: lock,
1319 }
1320 }
1321 }
1322
1323 impl Drop for EnvVarGuard {
1324 fn drop(&mut self) {
1325 match &self.previous {
1326 Some(v) => unsafe { std::env::set_var(self.name, v) },
1329 None => unsafe { std::env::remove_var(self.name) },
1330 }
1331 }
1332 }
1333
1334 #[test]
1335 fn evaluates_let_chain_and_interpolation() {
1336 let _guard = EnvVarGuard::absent("ALBA_TEST_PROFILE");
1337 let src = r#"
1338let profile = env("ALBA_TEST_PROFILE", "debug")
1339let release = profile == "release"
1340beam build { run "cargo build {if release then '--release' else ''}" }
1341"#;
1342 let project = load_str(src).unwrap(); let beam = &project.beams[0];
1344 let cmd = render_template(&beam.run[0], &beam.scope).unwrap();
1345 assert_eq!(cmd, "cargo build ");
1346 }
1347
1348 #[test]
1351 fn env_reads_a_variable_that_is_set() {
1352 let _guard = EnvVarGuard::set("ALBA_TEST_PROFILE", "release");
1353 let src = r#"
1354let profile = env("ALBA_TEST_PROFILE", "debug")
1355beam build { run "cargo build --{profile}" }
1356"#;
1357 let project = load_str(src).unwrap();
1358 let beam = &project.beams[0];
1359
1360 let cmd = render_template(&beam.run[0], &beam.scope).unwrap();
1361
1362 assert_eq!(cmd, "cargo build --release");
1363 }
1364
1365 #[test]
1366 fn unknown_variable_reports_span_and_help() {
1367 let err = load_str(r#"beam x { run "{profil}" }"#).unwrap_err();
1368 assert!(err.message.contains("unknown variable"));
1369 assert!(err.help.is_none());
1371 }
1372
1373 #[test]
1374 fn if_condition_must_be_bool() {
1375 let err = load_str(
1376 r#"
1377let x = "str"
1378beam b { run "{if x then 'a' else 'b'}" }
1379"#,
1380 )
1381 .unwrap_err();
1382 assert!(err.message.contains("expected a boolean"));
1383 }
1384
1385 #[test]
1395 fn beam_parameter_shadows_a_same_named_let() {
1396 let project = load_str(
1397 r#"
1398let target = true
1399beam deploy(target) { run "{target + '!'}" }
1400"#,
1401 )
1402 .unwrap();
1403 assert_eq!(project.beams[0].params, vec!["target".to_string()]);
1404 }
1405
1406 #[test]
1411 fn if_validates_the_not_taken_branch_too() {
1412 let err = load_str(
1413 r#"
1414let flag = false
1415beam b { run "{if flag then bogus else 'ok'}" }
1416"#,
1417 )
1418 .unwrap_err();
1419 assert!(err.message.contains("unknown variable"));
1420 assert!(err.message.contains("bogus"));
1421 }
1422
1423 #[test]
1427 fn load_time_field_rejects_parameter_even_when_shadowing_a_let() {
1428 let err = load_str(
1429 r#"
1430let target = "prod"
1431beam deploy(target) { cwd "{target}" run "echo {target}" }
1432"#,
1433 )
1434 .unwrap_err();
1435 assert!(err.message.contains("beam parameters cannot be used"));
1436 assert!(err.message.contains("cwd"));
1437 }
1438
1439 #[test]
1446 fn env_in_run_template_is_not_read_at_load_time() {
1447 let project =
1448 load_str(r#"beam deploy { run "curl -H {env('ALBA_PROBE_TOKEN_XYZ')}" }"#).unwrap();
1449 assert_eq!(project.beams[0].id.0, "deploy");
1450 }
1451
1452 #[test]
1458 fn load_time_field_validates_the_not_taken_branch_too() {
1459 let err = load_str(
1460 r#"
1461let flag = false
1462beam b { cwd "{if flag then bogus else '.'}" run "echo x" }
1463"#,
1464 )
1465 .unwrap_err();
1466 assert!(err.message.contains("unknown variable"));
1467 assert!(err.message.contains("bogus"));
1468 }
1469
1470 #[test]
1473 fn load_time_field_rejects_an_unknown_function_in_an_untaken_branch() {
1474 let err = load_str(
1475 r#"
1476let flag = false
1477beam b { description "{if flag then nope('x') else 'ok'}" run "echo x" }
1478"#,
1479 )
1480 .unwrap_err();
1481 assert!(err.message.contains("unknown function"));
1482 }
1483
1484 #[test]
1487 fn let_binding_validates_the_not_taken_branch_too() {
1488 let err = load_str(
1489 r#"
1490let flag = false
1491let path = if flag then bogus else "."
1492beam b { run "echo {path}" }
1493"#,
1494 )
1495 .unwrap_err();
1496 assert!(err.message.contains("unknown variable"));
1497 assert!(err.message.contains("bogus"));
1498 }
1499
1500 #[test]
1504 fn env_without_a_default_is_rejected_in_a_load_time_field() {
1505 let err = load_str(r#"beam b { description "{env('ALBA_NOPE_XYZ')}" run "echo x" }"#)
1506 .unwrap_err();
1507 assert!(err.message.contains("requires a default"));
1508 assert!(err.help.is_some());
1509 }
1510
1511 #[test]
1515 fn env_without_a_default_is_rejected_in_a_let_binding() {
1516 let err = load_str(
1517 r#"
1518let x = env("ALBA_TEST_ABSENT_XYZ")
1519beam b { run "x" }
1520"#,
1521 )
1522 .unwrap_err();
1523 assert!(err.message.contains("requires a default"));
1524 }
1525
1526 #[test]
1530 fn glob_arity_is_checked_even_when_an_argument_is_unknown() {
1531 let err = load_str(r#"beam b(p) { run "{glob(p, 'x')}" }"#).unwrap_err();
1532 assert!(
1533 err.message
1534 .contains("`glob` expects 1 argument(s), found 2")
1535 );
1536 }
1537
1538 #[test]
1539 fn glob_validates_pattern_and_returns_it_unchanged() {
1540 let project = load_str(
1541 r#"
1542let pattern = glob("src/**/*.rs")
1543beam b { run "{pattern}" }
1544"#,
1545 )
1546 .unwrap();
1547 assert_eq!(
1548 project.beams[0].scope.get("pattern"),
1549 Some(&Value::Str("src/**/*.rs".to_string()))
1550 );
1551 }
1552
1553 #[test]
1554 fn glob_rejects_invalid_pattern() {
1555 let err = load_str(
1556 r#"
1557let pattern = glob("[unclosed")
1558beam b { run "x" }
1559"#,
1560 )
1561 .unwrap_err();
1562 assert!(err.message.contains("invalid glob pattern"));
1563 }
1564
1565 #[test]
1570 fn inputs_reject_a_pattern_that_will_not_compile() {
1571 let err = load_str(
1572 r#"
1573beam b {
1574 inputs ["src/**/*.rs", "a[b"]
1575 run "x"
1576}
1577"#,
1578 )
1579 .unwrap_err();
1580 assert!(err.message.contains("invalid `inputs` pattern `a[b`"));
1581 assert!(err.message.contains("beam `b`"));
1582 }
1583
1584 #[test]
1585 fn outputs_reject_a_pattern_that_will_not_compile() {
1586 let err = load_str(
1587 r#"
1588beam b {
1589 outputs ["a[b"]
1590 run "x"
1591}
1592"#,
1593 )
1594 .unwrap_err();
1595 assert!(err.message.contains("invalid `outputs` pattern `a[b`"));
1596 }
1597
1598 #[test]
1606 fn the_glob_builtin_and_the_inputs_field_keep_their_own_dialects() {
1607 let err = load_str(
1608 r#"
1609let pattern = glob("src**/x.rs")
1610beam b { run "{pattern}" }
1611"#,
1612 )
1613 .unwrap_err();
1614 assert!(err.message.contains("invalid glob pattern `src**/x.rs`"));
1615
1616 load_str(
1617 r#"
1618beam b {
1619 inputs ["src**/x.rs"]
1620 run "x"
1621}
1622"#,
1623 )
1624 .expect("`globset` accepts this, and `globset` is what matches `inputs`");
1625 }
1626
1627 #[test]
1628 fn binary_operators_evaluate_correctly() {
1629 let project = load_str(
1630 r#"
1631let a = true
1632let b = false
1633let and_result = a && b
1634let or_result = a || b
1635let neq_result = a != b
1636let concat_result = "foo" + "bar"
1637beam x { run "ok" }
1638"#,
1639 )
1640 .unwrap();
1641 let scope = &project.beams[0].scope;
1642 assert_eq!(scope.get("and_result"), Some(&Value::Bool(false)));
1643 assert_eq!(scope.get("or_result"), Some(&Value::Bool(true)));
1644 assert_eq!(scope.get("neq_result"), Some(&Value::Bool(true)));
1645 assert_eq!(
1646 scope.get("concat_result"),
1647 Some(&Value::Str("foobar".to_string()))
1648 );
1649 }
1650
1651 #[test]
1652 fn unknown_function_errors_with_help() {
1653 let err = load_str(
1654 r#"
1655let x = evn("Y")
1656beam b { run "x" }
1657"#,
1658 )
1659 .unwrap_err();
1660 assert!(err.message.contains("unknown function"));
1661 assert_eq!(err.help.as_deref(), Some("did you mean `env`?"));
1662 }
1663
1664 #[test]
1665 fn executor_docker_carries_image() {
1666 let project =
1667 load_str(r#"beam deploy { executor docker { image "registry/app:latest" } run "x" }"#)
1668 .unwrap();
1669 assert_eq!(
1670 project.beams[0].executor,
1671 ExecutorKind::Docker {
1672 image: "registry/app:latest".to_string(),
1673 volumes: Vec::new(),
1674 workdir: None,
1675 }
1676 );
1677 }
1678
1679 #[test]
1680 fn executor_docker_carries_volumes_and_workdir() {
1681 let project = load_str(
1682 r#"beam d { executor docker { image "x" volumes ["h:/c"] workdir "/w" } run "x" }"#,
1683 )
1684 .unwrap();
1685 assert_eq!(
1686 project.beams[0].executor,
1687 ExecutorKind::Docker {
1688 image: "x".into(),
1689 volumes: vec!["h:/c".into()],
1690 workdir: Some("/w".into()),
1691 }
1692 );
1693 }
1694
1695 #[test]
1696 fn docker_volumes_must_be_a_list() {
1697 let err = load_str(r#"beam d { executor docker { image "x" volumes "h:/c" } run "x" }"#)
1698 .unwrap_err();
1699 assert!(
1700 err.message
1701 .contains("executor `docker` option `volumes` must be a list of strings")
1702 );
1703 }
1704
1705 #[test]
1706 fn docker_volume_entries_must_name_a_container_path() {
1707 let err =
1708 load_str(r#"beam d { executor docker { image "x" volumes ["nocolon"] } run "x" }"#)
1709 .unwrap_err();
1710 assert!(
1711 err.message
1712 .contains("invalid volume `nocolon`: expected `host:container`")
1713 );
1714 }
1715
1716 #[test]
1717 fn docker_workdir_must_be_an_absolute_container_path() {
1718 let err =
1719 load_str(r#"beam d { executor docker { image "x" workdir "relative" } run "x" }"#)
1720 .unwrap_err();
1721 assert!(
1722 err.message
1723 .contains("executor `docker` option `workdir` must be an absolute container path")
1724 );
1725 }
1726
1727 #[test]
1728 fn docker_rejects_an_unknown_option_with_a_suggestion() {
1729 let err = load_str(r#"beam d { executor docker { image "x" volume ["a:/b"] } run "x" }"#)
1730 .unwrap_err();
1731 assert!(
1732 err.message
1733 .contains("executor `docker` does not take an option `volume`")
1734 );
1735 assert_eq!(err.help.as_deref(), Some("did you mean `volumes`?"));
1736 }
1737
1738 #[test]
1739 fn an_unknown_executor_becomes_a_plugin_reference() {
1740 let project = load_str(
1741 r#"beam d { executor podman { image "x" remote true tags ["a", "b"] } run "x" }"#,
1742 )
1743 .unwrap();
1744 assert_eq!(
1745 project.beams[0].executor,
1746 ExecutorKind::Plugin {
1747 name: "podman".into(),
1748 options: vec![
1749 ("image".into(), OptionValue::Str("x".into())),
1750 ("remote".into(), OptionValue::Bool(true)),
1751 (
1752 "tags".into(),
1753 OptionValue::List(vec!["a".into(), "b".into()])
1754 ),
1755 ],
1756 }
1757 );
1758 }
1759
1760 #[test]
1761 fn shell_takes_no_options() {
1762 let err = load_str(r#"beam b { executor shell { image "x" } run "y" }"#).unwrap_err();
1763 assert!(err.message.contains("executor `shell` takes no options"));
1764 }
1765
1766 #[test]
1771 fn an_executor_name_near_docker_still_becomes_a_plugin_reference() {
1772 let project = load_str(r#"beam deploy { executor dokcer { image "x" } run "y" }"#).unwrap();
1773 assert_eq!(
1774 project.beams[0].executor,
1775 ExecutorKind::Plugin {
1776 name: "dokcer".to_string(),
1777 options: vec![("image".to_string(), OptionValue::Str("x".to_string()))],
1778 }
1779 );
1780 }
1781
1782 #[test]
1783 fn system_shell_executor_maps_to_its_kind() {
1784 let project = load_str("beam b {\n executor system_shell\n run \"x\"\n}").unwrap();
1785 assert_eq!(project.beams[0].executor, ExecutorKind::SystemShell);
1786 }
1787
1788 #[test]
1789 fn system_shell_rejects_options() {
1790 let err = load_str("beam b {\n executor system_shell { image \"i\" }\n run \"x\"\n}")
1791 .unwrap_err();
1792 assert!(
1793 err.message
1794 .contains("executor `system_shell` takes no options")
1795 );
1796 }
1797
1798 #[test]
1801 fn an_executor_name_near_system_shell_still_becomes_a_plugin_reference() {
1802 let project = load_str("beam b {\n executor system_shel\n run \"x\"\n}").unwrap();
1803 assert_eq!(
1804 project.beams[0].executor,
1805 ExecutorKind::Plugin {
1806 name: "system_shel".to_string(),
1807 options: Vec::new(),
1808 }
1809 );
1810 }
1811
1812 #[test]
1813 fn unknown_variable_suggests_close_match() {
1814 let err = load_str(
1815 r#"
1816let release = true
1817beam b { run "{relase}" }
1818"#,
1819 )
1820 .unwrap_err();
1821 assert_eq!(err.help.as_deref(), Some("did you mean `release`?"));
1822 }
1823
1824 #[test]
1829 fn suggest_breaks_distance_ties_alphabetically() {
1830 assert_eq!(suggest("hat", ["cat", "bat"].into_iter()), Some("bat"));
1832 assert_eq!(suggest("hat", ["bat", "cat"].into_iter()), Some("bat"));
1833 }
1834
1835 #[test]
1840 fn load_str_rejects_imports() {
1841 let err = load_str("import \"x/Beamfile\" as x\nbeam b { run \"echo\" }").unwrap_err();
1842 assert!(err.message.contains("imports are not supported"));
1843 }
1844}