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 if field == "inputs" && crate::files::has_inner_dot_component(&pattern) {
1108 return Err(CoreError::new(
1109 format!(
1110 "invalid `inputs` pattern `{pattern}` in beam `{beam}`: \
1111 `.` and `..` may only start a pattern"
1112 ),
1113 template.span,
1114 ));
1115 }
1116 patterns.push(pattern);
1117 }
1118 Ok(patterns)
1119}
1120
1121fn build_executor(
1122 decl: Option<&ExecutorDecl>,
1123 lets: &Scope,
1124 params: &[String],
1125) -> Result<ExecutorKind, CoreError> {
1126 let Some(decl) = decl else {
1127 return Ok(ExecutorKind::Shell);
1128 };
1129
1130 let mut options: Vec<(String, Span, OptionValue)> = Vec::new();
1133 for (key, value) in &decl.options {
1134 let rendered = match value {
1135 ExecutorOptionValue::Str(template) => OptionValue::Str(render_load_time_template(
1136 "executor options",
1137 template,
1138 lets,
1139 params,
1140 )?),
1141 ExecutorOptionValue::Bool(flag) => OptionValue::Bool(*flag),
1142 ExecutorOptionValue::List(templates) => OptionValue::List(
1143 templates
1144 .iter()
1145 .map(|t| render_load_time_template("executor options", t, lets, params))
1146 .collect::<Result<_, _>>()?,
1147 ),
1148 };
1149 options.push((key.value.clone(), key.span, rendered));
1150 }
1151
1152 match decl.name.value.as_str() {
1153 "shell" => no_options("shell", &options, decl).map(|()| ExecutorKind::Shell),
1154 "system_shell" => {
1155 no_options("system_shell", &options, decl).map(|()| ExecutorKind::SystemShell)
1156 }
1157 "docker" => build_docker(decl, options),
1158 other => Ok(ExecutorKind::Plugin {
1159 name: other.to_string(),
1160 options: options.into_iter().map(|(k, _, v)| (k, v)).collect(),
1161 }),
1162 }
1163}
1164
1165fn no_options(
1168 name: &str,
1169 options: &[(String, Span, OptionValue)],
1170 decl: &ExecutorDecl,
1171) -> Result<(), CoreError> {
1172 if options.is_empty() {
1173 Ok(())
1174 } else {
1175 Err(CoreError::new(
1176 format!("executor `{name}` takes no options"),
1177 decl.name.span,
1178 ))
1179 }
1180}
1181
1182fn build_docker(
1186 decl: &ExecutorDecl,
1187 options: Vec<(String, Span, OptionValue)>,
1188) -> Result<ExecutorKind, CoreError> {
1189 let mut image = None;
1190 let mut volumes = Vec::new();
1191 let mut workdir = None;
1192 for (key, span, value) in options {
1193 match (key.as_str(), value) {
1194 ("image", OptionValue::Str(v)) => image = Some(v),
1195 ("image", _) => {
1196 return Err(CoreError::new(
1197 "executor `docker` option `image` must be a string".to_string(),
1198 span,
1199 ));
1200 }
1201 ("volumes", OptionValue::List(entries)) => {
1202 for entry in &entries {
1203 let valid = matches!(entry.rsplit_once(':'), Some((host, path))
1206 if !host.is_empty() && path.starts_with('/'));
1207 if !valid {
1208 return Err(CoreError::new(
1209 format!("invalid volume `{entry}`: expected `host:container`"),
1210 span,
1211 ));
1212 }
1213 }
1214 volumes = entries;
1215 }
1216 ("volumes", _) => {
1217 return Err(CoreError::new(
1218 "executor `docker` option `volumes` must be a list of strings".to_string(),
1219 span,
1220 ));
1221 }
1222 ("workdir", OptionValue::Str(v)) if v.starts_with('/') => workdir = Some(v),
1223 ("workdir", _) => {
1224 return Err(CoreError::new(
1225 "executor `docker` option `workdir` must be an absolute container path"
1226 .to_string(),
1227 span,
1228 ));
1229 }
1230 (other, _) => {
1231 let err = CoreError::new(
1232 format!("executor `docker` does not take an option `{other}`"),
1233 span,
1234 );
1235 return Err(
1236 match suggest(other, ["image", "volumes", "workdir"].into_iter()) {
1237 Some(c) => err.with_help(format!("did you mean `{c}`?")),
1238 None => err,
1239 },
1240 );
1241 }
1242 }
1243 }
1244 let image = image.ok_or_else(|| {
1245 CoreError::new(
1246 "executor `docker` requires an `image` option".to_string(),
1247 decl.name.span,
1248 )
1249 })?;
1250 Ok(ExecutorKind::Docker {
1251 image,
1252 volumes,
1253 workdir,
1254 })
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259 use std::sync::{Mutex, MutexGuard};
1260
1261 use super::*;
1262
1263 static ENV_LOCK: Mutex<()> = Mutex::new(());
1271
1272 struct EnvVarGuard {
1278 name: &'static str,
1279 previous: Option<String>,
1280 _lock: MutexGuard<'static, ()>,
1284 }
1285
1286 impl EnvVarGuard {
1287 fn absent(name: &'static str) -> Self {
1289 Self::hold(name, None)
1290 }
1291
1292 fn set(name: &'static str, value: &str) -> Self {
1294 Self::hold(name, Some(value))
1295 }
1296
1297 fn hold(name: &'static str, value: Option<&str>) -> Self {
1298 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1304 let previous = std::env::var(name).ok();
1305 unsafe {
1319 match value {
1320 Some(value) => std::env::set_var(name, value),
1321 None => std::env::remove_var(name),
1322 }
1323 }
1324 Self {
1325 name,
1326 previous,
1327 _lock: lock,
1328 }
1329 }
1330 }
1331
1332 impl Drop for EnvVarGuard {
1333 fn drop(&mut self) {
1334 match &self.previous {
1335 Some(v) => unsafe { std::env::set_var(self.name, v) },
1338 None => unsafe { std::env::remove_var(self.name) },
1339 }
1340 }
1341 }
1342
1343 #[test]
1344 fn evaluates_let_chain_and_interpolation() {
1345 let _guard = EnvVarGuard::absent("ALBA_TEST_PROFILE");
1346 let src = r#"
1347let profile = env("ALBA_TEST_PROFILE", "debug")
1348let release = profile == "release"
1349beam build { run "cargo build {if release then '--release' else ''}" }
1350"#;
1351 let project = load_str(src).unwrap(); let beam = &project.beams[0];
1353 let cmd = render_template(&beam.run[0], &beam.scope).unwrap();
1354 assert_eq!(cmd, "cargo build ");
1355 }
1356
1357 #[test]
1360 fn env_reads_a_variable_that_is_set() {
1361 let _guard = EnvVarGuard::set("ALBA_TEST_PROFILE", "release");
1362 let src = r#"
1363let profile = env("ALBA_TEST_PROFILE", "debug")
1364beam build { run "cargo build --{profile}" }
1365"#;
1366 let project = load_str(src).unwrap();
1367 let beam = &project.beams[0];
1368
1369 let cmd = render_template(&beam.run[0], &beam.scope).unwrap();
1370
1371 assert_eq!(cmd, "cargo build --release");
1372 }
1373
1374 #[test]
1375 fn unknown_variable_reports_span_and_help() {
1376 let err = load_str(r#"beam x { run "{profil}" }"#).unwrap_err();
1377 assert!(err.message.contains("unknown variable"));
1378 assert!(err.help.is_none());
1380 }
1381
1382 #[test]
1383 fn if_condition_must_be_bool() {
1384 let err = load_str(
1385 r#"
1386let x = "str"
1387beam b { run "{if x then 'a' else 'b'}" }
1388"#,
1389 )
1390 .unwrap_err();
1391 assert!(err.message.contains("expected a boolean"));
1392 }
1393
1394 #[test]
1404 fn beam_parameter_shadows_a_same_named_let() {
1405 let project = load_str(
1406 r#"
1407let target = true
1408beam deploy(target) { run "{target + '!'}" }
1409"#,
1410 )
1411 .unwrap();
1412 assert_eq!(project.beams[0].params, vec!["target".to_string()]);
1413 }
1414
1415 #[test]
1420 fn if_validates_the_not_taken_branch_too() {
1421 let err = load_str(
1422 r#"
1423let flag = false
1424beam b { run "{if flag then bogus else 'ok'}" }
1425"#,
1426 )
1427 .unwrap_err();
1428 assert!(err.message.contains("unknown variable"));
1429 assert!(err.message.contains("bogus"));
1430 }
1431
1432 #[test]
1436 fn load_time_field_rejects_parameter_even_when_shadowing_a_let() {
1437 let err = load_str(
1438 r#"
1439let target = "prod"
1440beam deploy(target) { cwd "{target}" run "echo {target}" }
1441"#,
1442 )
1443 .unwrap_err();
1444 assert!(err.message.contains("beam parameters cannot be used"));
1445 assert!(err.message.contains("cwd"));
1446 }
1447
1448 #[test]
1455 fn env_in_run_template_is_not_read_at_load_time() {
1456 let project =
1457 load_str(r#"beam deploy { run "curl -H {env('ALBA_PROBE_TOKEN_XYZ')}" }"#).unwrap();
1458 assert_eq!(project.beams[0].id.0, "deploy");
1459 }
1460
1461 #[test]
1467 fn load_time_field_validates_the_not_taken_branch_too() {
1468 let err = load_str(
1469 r#"
1470let flag = false
1471beam b { cwd "{if flag then bogus else '.'}" run "echo x" }
1472"#,
1473 )
1474 .unwrap_err();
1475 assert!(err.message.contains("unknown variable"));
1476 assert!(err.message.contains("bogus"));
1477 }
1478
1479 #[test]
1482 fn load_time_field_rejects_an_unknown_function_in_an_untaken_branch() {
1483 let err = load_str(
1484 r#"
1485let flag = false
1486beam b { description "{if flag then nope('x') else 'ok'}" run "echo x" }
1487"#,
1488 )
1489 .unwrap_err();
1490 assert!(err.message.contains("unknown function"));
1491 }
1492
1493 #[test]
1496 fn let_binding_validates_the_not_taken_branch_too() {
1497 let err = load_str(
1498 r#"
1499let flag = false
1500let path = if flag then bogus else "."
1501beam b { run "echo {path}" }
1502"#,
1503 )
1504 .unwrap_err();
1505 assert!(err.message.contains("unknown variable"));
1506 assert!(err.message.contains("bogus"));
1507 }
1508
1509 #[test]
1513 fn env_without_a_default_is_rejected_in_a_load_time_field() {
1514 let err = load_str(r#"beam b { description "{env('ALBA_NOPE_XYZ')}" run "echo x" }"#)
1515 .unwrap_err();
1516 assert!(err.message.contains("requires a default"));
1517 assert!(err.help.is_some());
1518 }
1519
1520 #[test]
1524 fn env_without_a_default_is_rejected_in_a_let_binding() {
1525 let err = load_str(
1526 r#"
1527let x = env("ALBA_TEST_ABSENT_XYZ")
1528beam b { run "x" }
1529"#,
1530 )
1531 .unwrap_err();
1532 assert!(err.message.contains("requires a default"));
1533 }
1534
1535 #[test]
1539 fn glob_arity_is_checked_even_when_an_argument_is_unknown() {
1540 let err = load_str(r#"beam b(p) { run "{glob(p, 'x')}" }"#).unwrap_err();
1541 assert!(
1542 err.message
1543 .contains("`glob` expects 1 argument(s), found 2")
1544 );
1545 }
1546
1547 #[test]
1548 fn glob_validates_pattern_and_returns_it_unchanged() {
1549 let project = load_str(
1550 r#"
1551let pattern = glob("src/**/*.rs")
1552beam b { run "{pattern}" }
1553"#,
1554 )
1555 .unwrap();
1556 assert_eq!(
1557 project.beams[0].scope.get("pattern"),
1558 Some(&Value::Str("src/**/*.rs".to_string()))
1559 );
1560 }
1561
1562 #[test]
1563 fn glob_rejects_invalid_pattern() {
1564 let err = load_str(
1565 r#"
1566let pattern = glob("[unclosed")
1567beam b { run "x" }
1568"#,
1569 )
1570 .unwrap_err();
1571 assert!(err.message.contains("invalid glob pattern"));
1572 }
1573
1574 #[test]
1579 fn inputs_reject_a_pattern_that_will_not_compile() {
1580 let err = load_str(
1581 r#"
1582beam b {
1583 inputs ["src/**/*.rs", "a[b"]
1584 run "x"
1585}
1586"#,
1587 )
1588 .unwrap_err();
1589 assert!(err.message.contains("invalid `inputs` pattern `a[b`"));
1590 assert!(err.message.contains("beam `b`"));
1591 }
1592
1593 #[test]
1598 fn inputs_reject_a_dot_component_past_the_start() {
1599 for pattern in ["src/../Dockerfile", "src/./a.rs", "../src/**/../x"] {
1600 let source = format!("beam b {{\n inputs [\"{pattern}\"]\n run \"x\"\n}}\n");
1601 let err = load_str(&source).unwrap_err();
1602 assert!(
1603 err.message
1604 .contains(&format!("invalid `inputs` pattern `{pattern}`")),
1605 "{}",
1606 err.message
1607 );
1608 }
1609
1610 load_str(
1611 r#"
1612beam b {
1613 inputs ["../../Dockerfile", "./src/*.rs"]
1614 run "x"
1615}
1616"#,
1617 )
1618 .expect("leading `.` and `..` components are anchored, not rejected");
1619 }
1620
1621 #[test]
1622 fn outputs_reject_a_pattern_that_will_not_compile() {
1623 let err = load_str(
1624 r#"
1625beam b {
1626 outputs ["a[b"]
1627 run "x"
1628}
1629"#,
1630 )
1631 .unwrap_err();
1632 assert!(err.message.contains("invalid `outputs` pattern `a[b`"));
1633 }
1634
1635 #[test]
1643 fn the_glob_builtin_and_the_inputs_field_keep_their_own_dialects() {
1644 let err = load_str(
1645 r#"
1646let pattern = glob("src**/x.rs")
1647beam b { run "{pattern}" }
1648"#,
1649 )
1650 .unwrap_err();
1651 assert!(err.message.contains("invalid glob pattern `src**/x.rs`"));
1652
1653 load_str(
1654 r#"
1655beam b {
1656 inputs ["src**/x.rs"]
1657 run "x"
1658}
1659"#,
1660 )
1661 .expect("`globset` accepts this, and `globset` is what matches `inputs`");
1662 }
1663
1664 #[test]
1665 fn binary_operators_evaluate_correctly() {
1666 let project = load_str(
1667 r#"
1668let a = true
1669let b = false
1670let and_result = a && b
1671let or_result = a || b
1672let neq_result = a != b
1673let concat_result = "foo" + "bar"
1674beam x { run "ok" }
1675"#,
1676 )
1677 .unwrap();
1678 let scope = &project.beams[0].scope;
1679 assert_eq!(scope.get("and_result"), Some(&Value::Bool(false)));
1680 assert_eq!(scope.get("or_result"), Some(&Value::Bool(true)));
1681 assert_eq!(scope.get("neq_result"), Some(&Value::Bool(true)));
1682 assert_eq!(
1683 scope.get("concat_result"),
1684 Some(&Value::Str("foobar".to_string()))
1685 );
1686 }
1687
1688 #[test]
1689 fn unknown_function_errors_with_help() {
1690 let err = load_str(
1691 r#"
1692let x = evn("Y")
1693beam b { run "x" }
1694"#,
1695 )
1696 .unwrap_err();
1697 assert!(err.message.contains("unknown function"));
1698 assert_eq!(err.help.as_deref(), Some("did you mean `env`?"));
1699 }
1700
1701 #[test]
1702 fn executor_docker_carries_image() {
1703 let project =
1704 load_str(r#"beam deploy { executor docker { image "registry/app:latest" } run "x" }"#)
1705 .unwrap();
1706 assert_eq!(
1707 project.beams[0].executor,
1708 ExecutorKind::Docker {
1709 image: "registry/app:latest".to_string(),
1710 volumes: Vec::new(),
1711 workdir: None,
1712 }
1713 );
1714 }
1715
1716 #[test]
1717 fn executor_docker_carries_volumes_and_workdir() {
1718 let project = load_str(
1719 r#"beam d { executor docker { image "x" volumes ["h:/c"] workdir "/w" } run "x" }"#,
1720 )
1721 .unwrap();
1722 assert_eq!(
1723 project.beams[0].executor,
1724 ExecutorKind::Docker {
1725 image: "x".into(),
1726 volumes: vec!["h:/c".into()],
1727 workdir: Some("/w".into()),
1728 }
1729 );
1730 }
1731
1732 #[test]
1733 fn docker_volumes_must_be_a_list() {
1734 let err = load_str(r#"beam d { executor docker { image "x" volumes "h:/c" } run "x" }"#)
1735 .unwrap_err();
1736 assert!(
1737 err.message
1738 .contains("executor `docker` option `volumes` must be a list of strings")
1739 );
1740 }
1741
1742 #[test]
1743 fn docker_volume_entries_must_name_a_container_path() {
1744 let err =
1745 load_str(r#"beam d { executor docker { image "x" volumes ["nocolon"] } run "x" }"#)
1746 .unwrap_err();
1747 assert!(
1748 err.message
1749 .contains("invalid volume `nocolon`: expected `host:container`")
1750 );
1751 }
1752
1753 #[test]
1754 fn docker_workdir_must_be_an_absolute_container_path() {
1755 let err =
1756 load_str(r#"beam d { executor docker { image "x" workdir "relative" } run "x" }"#)
1757 .unwrap_err();
1758 assert!(
1759 err.message
1760 .contains("executor `docker` option `workdir` must be an absolute container path")
1761 );
1762 }
1763
1764 #[test]
1765 fn docker_rejects_an_unknown_option_with_a_suggestion() {
1766 let err = load_str(r#"beam d { executor docker { image "x" volume ["a:/b"] } run "x" }"#)
1767 .unwrap_err();
1768 assert!(
1769 err.message
1770 .contains("executor `docker` does not take an option `volume`")
1771 );
1772 assert_eq!(err.help.as_deref(), Some("did you mean `volumes`?"));
1773 }
1774
1775 #[test]
1776 fn an_unknown_executor_becomes_a_plugin_reference() {
1777 let project = load_str(
1778 r#"beam d { executor podman { image "x" remote true tags ["a", "b"] } run "x" }"#,
1779 )
1780 .unwrap();
1781 assert_eq!(
1782 project.beams[0].executor,
1783 ExecutorKind::Plugin {
1784 name: "podman".into(),
1785 options: vec![
1786 ("image".into(), OptionValue::Str("x".into())),
1787 ("remote".into(), OptionValue::Bool(true)),
1788 (
1789 "tags".into(),
1790 OptionValue::List(vec!["a".into(), "b".into()])
1791 ),
1792 ],
1793 }
1794 );
1795 }
1796
1797 #[test]
1798 fn shell_takes_no_options() {
1799 let err = load_str(r#"beam b { executor shell { image "x" } run "y" }"#).unwrap_err();
1800 assert!(err.message.contains("executor `shell` takes no options"));
1801 }
1802
1803 #[test]
1808 fn an_executor_name_near_docker_still_becomes_a_plugin_reference() {
1809 let project = load_str(r#"beam deploy { executor dokcer { image "x" } run "y" }"#).unwrap();
1810 assert_eq!(
1811 project.beams[0].executor,
1812 ExecutorKind::Plugin {
1813 name: "dokcer".to_string(),
1814 options: vec![("image".to_string(), OptionValue::Str("x".to_string()))],
1815 }
1816 );
1817 }
1818
1819 #[test]
1820 fn system_shell_executor_maps_to_its_kind() {
1821 let project = load_str("beam b {\n executor system_shell\n run \"x\"\n}").unwrap();
1822 assert_eq!(project.beams[0].executor, ExecutorKind::SystemShell);
1823 }
1824
1825 #[test]
1826 fn system_shell_rejects_options() {
1827 let err = load_str("beam b {\n executor system_shell { image \"i\" }\n run \"x\"\n}")
1828 .unwrap_err();
1829 assert!(
1830 err.message
1831 .contains("executor `system_shell` takes no options")
1832 );
1833 }
1834
1835 #[test]
1838 fn an_executor_name_near_system_shell_still_becomes_a_plugin_reference() {
1839 let project = load_str("beam b {\n executor system_shel\n run \"x\"\n}").unwrap();
1840 assert_eq!(
1841 project.beams[0].executor,
1842 ExecutorKind::Plugin {
1843 name: "system_shel".to_string(),
1844 options: Vec::new(),
1845 }
1846 );
1847 }
1848
1849 #[test]
1850 fn unknown_variable_suggests_close_match() {
1851 let err = load_str(
1852 r#"
1853let release = true
1854beam b { run "{relase}" }
1855"#,
1856 )
1857 .unwrap_err();
1858 assert_eq!(err.help.as_deref(), Some("did you mean `release`?"));
1859 }
1860
1861 #[test]
1866 fn suggest_breaks_distance_ties_alphabetically() {
1867 assert_eq!(suggest("hat", ["cat", "bat"].into_iter()), Some("bat"));
1869 assert_eq!(suggest("hat", ["bat", "cat"].into_iter()), Some("bat"));
1870 }
1871
1872 #[test]
1877 fn load_str_rejects_imports() {
1878 let err = load_str("import \"x/Beamfile\" as x\nbeam b { run \"echo\" }").unwrap_err();
1879 assert!(err.message.contains("imports are not supported"));
1880 }
1881}