Skip to main content

alba_core/
eval.rs

1//! Expression evaluation, template rendering, and the [`Scope`] they run
2//! against — plus the single-file `load_str` orchestration that walks a
3//! parsed [`File`] into a [`Project`].
4//!
5//! ## Load-time vs. schedule-time rendering
6//!
7//! A beam's `description`, `inputs`, `outputs`, `cwd`, and executor
8//! options are rendered once, here, at load time, using a [`Scope`] built
9//! only from the file's `let` bindings. A beam's own parameter is never in
10//! that scope, so referencing one there is always an error — but a
11//! same-named `let` could otherwise silently take its place instead of
12//! erroring, which is why `render_load_time_template` explicitly rejects
13//! any `Var` matching one of the beam's parameter names (via
14//! `reject_param_references`) *before* rendering, rather than relying on
15//! the scope's shape alone.
16//!
17//! `run` and `env` values stay as [`StringTemplate`]s in the model,
18//! rendered later (at schedule time, by the engine, through the same
19//! [`render_template`] used here) against a [`Scope`] that also has the
20//! beam's parameters bound via [`Scope::with_params`]. Because parameters
21//! aren't bound yet at load time, those templates can't be fully
22//! evaluated up front — but every name they reference must still resolve
23//! to *something* (a file-level `let` or one of the beam's own
24//! parameters) at load time.
25//!
26//! ## Validation is a pass of its own, on both paths
27//!
28//! [`validate_template`] walks an expression tree via [`check_expr`]
29//! without evaluating it, treating a reference to a file-level `let` as
30//! concretely known (since those are already evaluated; a same-named
31//! parameter still shadows it, matching [`Scope::with_params`]) and a
32//! reference to one of the beam's own parameters as
33//! validly-named-but-unknown-until-schedule-time, and eagerly type-checks
34//! (reusing [`eval_binary`]) any sub-expression that turns out to depend
35//! only on `let`s. That is what makes `if_condition_must_be_bool` (whose
36//! `if` condition is a plain `let`, with no parameter involved) catchable
37//! at load time, while a `run` template that mixes `let`s and parameters
38//! only gets its names checked, not its types, until schedule time.
39//!
40//! Crucially this pass runs for load-time fields too, *in addition to*
41//! evaluating them. Evaluation alone walks only the branch an `if`
42//! condition selects, and which branch that is can depend on the
43//! environment the file happens to be loaded in (a condition derived from
44//! `env(...)`, say). Validating only the taken branch would therefore make
45//! a diagnostic environment-dependent: a typo hiding in the other branch
46//! would fail on one machine and pass on the next, for a file nobody
47//! edited. [`check_expr`] validates every branch regardless.
48//!
49//! ## `env()` and where it may appear
50//!
51//! `env()` reads the process environment, and [`check_expr`] never
52//! executes it: on the schedule-time path the read belongs to the moment
53//! the beam runs, not to loading. On the load-time path the read does
54//! happen (that is what `let profile = env("PROFILE", "debug")` is for),
55//! but only with a default in hand: `env(name)` with no default is
56//! rejected at its call site there, because otherwise one unset variable
57//! would make the whole project unloadable — including for beams nobody
58//! asked to run — and `alba check` would answer differently depending on
59//! the machine. Without a default, `env()` belongs in `run` or `env`,
60//! where it resolves when the beam runs. `glob()` has no such side effect
61//! (it only compiles a pattern), so it is validated eagerly on both paths
62//! as soon as its argument is known.
63
64use 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
77/// The built-in functions `eval_expr` recognizes in a `Call` expression.
78const BUILTIN_FUNCTIONS: &[&str] = &["env", "glob"];
79
80/// Name bindings available while evaluating expressions: a file's
81/// evaluated `let`s, later composed with a beam's own parameter values.
82///
83/// Cloning is a plain `HashMap` clone. A Beamfile binds a handful of
84/// `let`s and a beam takes a handful of parameters, so this stays cheap in
85/// practice; a persistent/structural-sharing map would be premature
86/// machinery for that size.
87#[derive(Debug, Clone, Default, PartialEq)]
88pub struct Scope {
89    values: HashMap<String, Value>,
90    git: Option<Arc<LazyGit>>,
91}
92
93/// The `git` object's values, computed on first access and shared by every
94/// scope of one load: a Beamfile that never reads `git` never spawns it,
95/// and one that reads it in ten places spawns it once.
96#[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
118/// Two scopes from the same load share one `LazyGit`; comparing the root
119/// is what "same git" means, and keeps `Scope: PartialEq`.
120impl PartialEq for LazyGit {
121    fn eq(&self, other: &Self) -> bool {
122        self.root == other.root
123    }
124}
125
126impl Scope {
127    /// An empty scope: no `let`s, no parameters.
128    pub fn empty() -> Self {
129        Self::default()
130    }
131
132    /// A scope with no `let`s or parameters yet, but with `git` wired up
133    /// so `git.*` field access resolves once an expression reads it.
134    pub(crate) fn with_git(git: Arc<LazyGit>) -> Self {
135        Self {
136            values: HashMap::new(),
137            git: Some(git),
138        }
139    }
140
141    /// The `git.<field>` value, or the error to report at `span`. Callers
142    /// have already run `check_field` (below), so a bad object or field
143    /// name never reaches here: the only remaining way to fail is `git`
144    /// being unavailable in this scope, or the real `git.head()` call
145    /// itself failing.
146    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    /// Binds `name` to `value` in place. `pub(crate)`: only this module's
163    /// `let`-chain evaluation builds a scope up incrementally; external
164    /// callers only ever compose scopes via [`Scope::with_params`].
165    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    /// The names currently bound in this scope, for "did you mean...?"
174    /// suggestions on an unknown-variable error.
175    pub fn names(&self) -> impl Iterator<Item = &str> {
176        self.values.keys().map(String::as_str)
177    }
178
179    /// Returns a new scope with `params[i]` bound to `args[i]` for each
180    /// `i`, layered on top of `self`'s existing bindings (a parameter name
181    /// that collides with a `let` shadows it for the new scope). This is
182    /// the cheap, obvious composition the engine uses to bind a beam's
183    /// positional arguments before rendering its `run`/`env` templates:
184    /// `beam.scope.with_params(&beam.params, &args)`.
185    ///
186    /// Mismatched lengths are not validated here: `zip` simply stops at
187    /// the shorter of the two, so a missing argument leaves its parameter
188    /// name unbound (rendering later fails with a plain "unknown
189    /// variable" error, not an arity message) and an extra argument is
190    /// silently ignored. The engine checks `args.len() ==
191    /// beam.params.len()` itself before calling this, so a real
192    /// argument-count mismatch gets a clear diagnostic instead of
193    /// surfacing here as a confusing unknown-variable one.
194    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
206/// The most precise span available for `expr`, per `alba_syntax`'s span
207/// policy: `Str` (via its `StringTemplate`), `Var`, and `Call` (via their
208/// name) carry one; `Bool`, `Binary`, and `If` do not. Callers fall back to
209/// an enclosing span (a `StringTemplate`'s span, or a fallback threaded
210/// down from one) when this returns `None`.
211fn 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
221/// `object.field`: only `git` has fields, and only the four values below
222/// exist on it. Spawn-free: this alone is what `check_expr` runs, so
223/// `git.tag` or `foo.bar` is rejected at load time without ever touching
224/// git. Shared with `eval_field`, which extends this with the real
225/// `git.head()` lookup once a value is actually needed.
226fn 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
245/// `object.field`, evaluated for real: [`check_field`]'s spawn-free name
246/// check, then the actual `git.head()` lookup through the scope.
247fn 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
256/// Classic dynamic-programming Levenshtein edit distance between two
257/// strings, operating on `char`s so it stays correct for non-ASCII names.
258/// Duplicated from `alba_syntax::parser`'s private `levenshtein` (not
259/// exported across the crate boundary) rather than shared, so this crate's
260/// "did you mean...?" suggestions use the exact same algorithm and
261/// distance-2 threshold as the parser's unknown-field suggestions, for a
262/// consistent feel across the DSL's diagnostics.
263fn 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
284/// The closest name in `candidates` to `name`, if within Levenshtein
285/// distance 2, matching `alba_syntax`'s unknown-field suggestion
286/// threshold. Ties are broken alphabetically, not by iteration order:
287/// `candidates` is typically a `HashMap`'s keys (via `Scope::names`),
288/// whose iteration order is randomized per process, so without an
289/// explicit tie-break the suggestion offered for the same typo could
290/// change from run to run.
291///
292/// `pub`: `graph.rs`'s "unknown beam" and "unknown target" diagnostics
293/// reuse this exact function (and its tie-break rule) for their own "did
294/// you mean...?" help, rather than duplicating it a third time alongside
295/// `alba_syntax::parser`'s private original — and `alba-engine`'s plugin
296/// resolution reuses it again for a missing `alba-executor-<name>`
297/// binary's own did-you-mean help.
298pub 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
326/// Renders `template` against `scope`: literal parts pass through
327/// unchanged, `{expr}` parts are evaluated and interpolated (strings
328/// verbatim, booleans as `true`/`false`).
329pub 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
344/// Evaluates `expr` against `scope`. The public entry point; internally
345/// delegates to a private helper that threads a span to fall back on for
346/// spanless expression kinds (`Bool`, `Binary`, `If`) down through
347/// recursive calls. Called directly (rather than through
348/// [`render_template`] or the deferred-template checker below, both of
349/// which have richer context to use as that fallback), the best available
350/// fallback is `expr`'s own span if it has one, or a zero-width span at
351/// the start of the source as a last resort.
352pub 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
468/// Requires `value` to be a string, for built-in function arguments
469/// (`env`'s name/default, `glob`'s pattern) that are never meaningfully
470/// anything else. Shared by [`expect_str_arg`] (which evaluates the
471/// argument first) and [`check_deferred_expr`]'s `glob` handling (which
472/// already has the argument's statically known value in hand, and would
473/// otherwise have to re-evaluate it to reach this same check).
474fn 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
487/// Evaluates `arg` and requires the result to be a string.
488fn 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
499/// `env(name)` / `env(name, default)`: reads the process environment.
500fn 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
525/// Validates that `pattern` compiles as a glob pattern (expansion is the
526/// cache's job, later) and returns it unchanged, wrapped as a [`Value`].
527/// Shared by [`eval_glob`] and [`check_deferred_expr`]'s `glob` handling.
528fn 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
534/// `glob(pattern)`: validates that the pattern compiles and returns it
535/// unchanged as a string.
536fn 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
550/// What [`check_expr`] statically knows about a sub-expression: either its
551/// concrete value (it only touched file-level `let`s, which are already
552/// evaluated) or that it depends on something not known yet — one of the
553/// beam's own parameters, or the process environment.
554enum StaticValue {
555    Known(Value),
556    Unknown,
557}
558
559/// When the expression being validated will actually be evaluated, which
560/// is the one thing [`check_expr`] treats differently between the two
561/// paths: whether `env()` may be written without a default. See the module
562/// doc comment.
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564enum Timing {
565    /// `let` bindings and the beam fields evaluated during the load itself
566    /// (`description`, `inputs`, `outputs`, `cwd`, executor options).
567    Load,
568    /// `run` commands and `env` values, rendered when the beam is about to
569    /// run, with its parameters bound.
570    Schedule,
571}
572
573/// Walks `expr`, validating variable and function names and eagerly
574/// type-checking whatever sub-expressions don't depend on `params` or on
575/// the environment. See the module doc comment for the overall strategy —
576/// in particular, *every* branch of an `if` is validated here, even one a
577/// statically known condition doesn't take, and `env()` is never actually
578/// executed on this path.
579fn 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            // Parameters are checked first: `Scope::with_params` has a
590            // same-named parameter shadow a `let` at schedule time, so a
591            // shadowed `let`'s value must not be used here either — doing
592            // so would validate against a value the beam will never
593            // actually see once its parameter is bound.
594            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            // Builds the rendered string directly from each part's
605            // already-computed `StaticValue` rather than recursing once
606            // to check it and then calling `render_template` again on the
607            // same subtree, which would (among other redundant work)
608            // evaluate any `glob()` call inside it twice.
609            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        // Dispatched by name, exactly like `eval_call`, so adding a third
630        // built-in cannot silently fall through into another one's
631        // argument handling.
632        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                // At least one operand depends on a parameter: its real
657                // type is only known once that parameter is bound, so skip
658                // type-checking this operation until schedule time.
659                _ => 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                    // Both branches are validated regardless of which one
673                    // the condition picks: an untaken branch can still
674                    // reference a nonexistent name, and which branch is
675                    // "untaken" can depend on the environment the file
676                    // happens to be loaded in (e.g. a condition derived
677                    // from `env(...)`) — that must not make the
678                    // diagnostic environment-dependent too.
679                    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                    // Which branch runs isn't decided yet; still validate
692                    // names and types in both.
693                    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        // Spawn-free name check only: whether `object` has fields and
700        // whether `field` is one of `git`'s four. The real `git.head()`
701        // lookup happens only at evaluation time (`eval_field`, via
702        // `eval_with_fallback`), never here — otherwise a beam whose
703        // condition never takes the branch that reads `git` would still
704        // spawn it just because `check_expr` validates both branches of an
705        // `if` (see above). `env()` calls defer the same way, for the same
706        // reason.
707        Expr::Field { object, field } => {
708            check_field(object, field)?;
709            Ok(StaticValue::Unknown)
710        }
711    }
712}
713
714/// Validates an `env(name)` / `env(name, default)` call without executing
715/// it. Arity is checked before the arguments are walked, so a miscall is
716/// reported as one however little is known about what it was given.
717fn 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    // Never read here: at schedule time the read belongs to the moment the
743    // beam runs, and at load time the value still depends on the ambient
744    // environment, so nothing downstream may be type-checked against it.
745    Ok(StaticValue::Unknown)
746}
747
748/// Validates a `glob(pattern)` call, compiling the pattern when it is
749/// already known. Compiling has no side effect, so unlike `env` this one
750/// really is evaluated here.
751fn 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
775/// Validates a template without rendering it: every `Var` it references
776/// must be either a file-level `let` or one of `params` (the beam's own
777/// parameters, unbound until schedule time), every branch of every `if`
778/// included. See the module doc comment for why this also happens to catch
779/// some type errors (like `if_condition_must_be_bool`) early.
780fn 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
795/// Rejects any reference to one of `params` inside `expr`, tagged with
796/// `field` for the error message. Used for load-time fields
797/// (`description`, `inputs`, `outputs`, `cwd`, executor options), where a
798/// beam's own parameters are never in scope — but without this explicit
799/// check, a same-named `let` would silently take a forbidden parameter
800/// reference's place instead of erroring (the load-time mirror of the
801/// parameter/`let` shadowing `check_deferred_expr` already accounts for
802/// on the deferred side).
803fn 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
848/// Renders a load-time field's template, in three steps.
849///
850/// First, `params` may never appear in it (see the module doc comment),
851/// checked explicitly so a same-named `let` can't silently mask a
852/// forbidden parameter reference. Then the whole template is validated
853/// without being evaluated — rendering alone would only walk the branch an
854/// `if` condition happens to select, which is what would otherwise make a
855/// name error here depend on the machine the file is loaded on. Only then
856/// is it rendered, against `lets` alone.
857fn 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
868/// Converts a lexer/parser failure into a [`CoreError`], stamped with
869/// whichever file is currently being loaded (see [`SourceIdScope`]).
870/// Shared by [`load_str`] and [`crate::loader`], so a parse failure looks
871/// identical regardless of whether it came from the single-file front door
872/// or from a file loaded as part of an `import` chain.
873pub(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/// Parses `source` as a single Beamfile and evaluates it into a
882/// [`Project`]: file-level `let`s in order, then every `beam` declaration.
883/// Single-file loading only — no `import` resolution, which is
884/// [`crate::loader::load_project`]'s job.
885///
886/// `#[doc(hidden)]` rather than private: `loader`'s tests and the future
887/// engine's tests reuse this exact helper (as `alba_core::load_str`) to
888/// build a `Project` from an inline source string without needing a real
889/// file on disk, but it isn't part of this crate's supported public API —
890/// real callers go through `alba_core::load_project` instead.
891///
892/// Runs [`crate::graph::validate_graph`] before returning, exactly like
893/// [`crate::loader::load_project`] does — so the engine's tests, which
894/// build their `Project`s through this function, see the same unknown-
895/// `needs`/cycle errors a real multi-file load would produce, rather than
896/// the two entry points drifting apart on what counts as a valid graph.
897///
898/// Every beam this produces carries `SourceId(0)`, matching
899/// [`crate::loader::load_project`]'s convention for the root file, and a
900/// `dir` of `"."`. That second part is *not* the same convention
901/// `load_project` uses: it resolves `Beam::dir` to an absolute path via
902/// `std::path::absolute`, while this function has no real file on disk to
903/// resolve `cwd` against, so it leaves `dir` as the literal, relative
904/// `"."` instead.
905///
906/// `import` declarations are rejected outright rather than silently
907/// ignored: this function has no filesystem to resolve them against
908/// (there's no "importing file directory" for an inline string), so
909/// silently dropping them would let a source string that accidentally
910/// includes an `import` load successfully with beams quietly missing,
911/// instead of failing loudly. Real `import` resolution is
912/// `load_project`'s job.
913#[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
933/// Evaluates an already-parsed [`File`] into a [`Project`]: file-level
934/// `let`s in order, then every `beam` declaration, each stamped with
935/// `dir` and with whichever `SourceId` is currently active (see
936/// [`SourceIdScope`]). Does not resolve `file.imports` — that recursion,
937/// and the namespace prefixing it requires, belongs to
938/// [`crate::loader::load_project`], which calls this once per file.
939pub(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        // Validated before being evaluated, for the same reason a
948        // load-time field is: evaluation only walks the branch an `if`
949        // condition selects, so a name error hiding in the other one would
950        // otherwise surface (or not) depending on the environment.
951        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    // An import's own `hook` declarations are ignored, exactly as its
969    // `default` is (see `crate::loader::Loader::load_file_body`) — so the
970    // unknown-name and duplicate checks below must only ever run for the
971    // root Beamfile. Running them unconditionally would fail the *whole*
972    // load over a hook problem in a file whose hooks are never even kept.
973    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, &params))
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, &params)?;
1037    let outputs = build_patterns("outputs", beam_name, &decl.outputs, lets, &params)?;
1038
1039    for run_template in &decl.run {
1040        validate_template(run_template, lets, &params, Timing::Schedule)?;
1041    }
1042    for (_, env_template) in &decl.env {
1043        validate_template(env_template, lets, &params, Timing::Schedule)?;
1044    }
1045
1046    let cwd = decl
1047        .cwd
1048        .as_ref()
1049        .map(|t| render_load_time_template("cwd", t, lets, &params))
1050        .transpose()?;
1051    let executor = build_executor(decl.executor.as_ref(), lets, &params)?;
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
1076/// Renders a beam's `inputs` or `outputs` templates and checks that each
1077/// result is a pattern the matcher can actually compile.
1078///
1079/// Compiled with `globset`, which is what [`crate::expand_globs`] matches
1080/// with — deliberately *not* `glob::Pattern`, the dialect the `glob()`
1081/// built-in validates against (see [`validate_glob_pattern`]). The two
1082/// accept different languages (`src**/x.rs` compiles under one and not the
1083/// other), so checking a pattern against the wrong one either rejects
1084/// something the matcher would have handled or lets through something it
1085/// cannot compile. The second is the damaging direction: an `inputs`
1086/// pattern that only fails at match time silently matches no file, and a
1087/// beam whose inputs match no file looks unchanged forever.
1088fn 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    // Render every option at load time, whatever the kind, so a malformed
1131    // interpolation anywhere in the block is still caught.
1132    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
1165/// Rejects any option on an executor kind that takes none (`shell`,
1166/// `system_shell`).
1167fn 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
1182/// Builds `ExecutorKind::Docker` from its already-rendered options:
1183/// `image` (required), `volumes` (a list of `host:container` entries), and
1184/// `workdir` (an absolute container path).
1185fn 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                    // `rsplit_once` so a windows host path (`C:\cache`)
1204                    // keeps its drive colon; only the last `:` splits.
1205                    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    /// Serializes every test in this crate that touches the process
1264    /// environment. `libtest` runs test functions on several threads by
1265    /// default, and `setenv`/`unsetenv` reallocate the whole `environ`
1266    /// block, so two tests mutating *different* names still race each
1267    /// other. Held for a guard's entire lifetime, so a test's read of the
1268    /// variable it just set cannot be interleaved with another test's
1269    /// write.
1270    static ENV_LOCK: Mutex<()> = Mutex::new(());
1271
1272    /// Forces a variable to a known state for the duration of a test,
1273    /// regardless of what the ambient environment happens to hold, and
1274    /// restores whatever was there afterward — even if the test panics.
1275    /// Tests that read a variable through the real `env()` built-in would
1276    /// otherwise depend on whatever shell happens to run them.
1277    struct EnvVarGuard {
1278        name: &'static str,
1279        previous: Option<String>,
1280        /// Dropped last (after the restore in `Drop`, which runs before
1281        /// the struct's fields are dropped), so no other guard can take
1282        /// the lock until this one has put the variable back.
1283        _lock: MutexGuard<'static, ()>,
1284    }
1285
1286    impl EnvVarGuard {
1287        /// Ensures `name` is unset for this guard's lifetime.
1288        fn absent(name: &'static str) -> Self {
1289            Self::hold(name, None)
1290        }
1291
1292        /// Ensures `name` holds `value` for this guard's lifetime.
1293        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            // A poisoned lock means some earlier test panicked while
1299            // holding it; its own `Drop` still restored the variable, so
1300            // the environment is in a usable state and there is nothing to
1301            // recover. Taking the guard anyway keeps one failing test from
1302            // cascading into every other environment test.
1303            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1304            let previous = std::env::var(name).ok();
1305            // SAFETY: `set_var`/`remove_var` are `unsafe` because mutating
1306            // the process environment while another thread reads or writes
1307            // it is unsound. `ENV_LOCK` rules out the writer half
1308            // completely: it is the only path in this crate that mutates
1309            // the environment, and it is held here. The reader half is not
1310            // fully ruled out and cannot be from inside this crate —
1311            // `std` and other crates read the environment at moments we do
1312            // not control (`RUST_BACKTRACE` on a panic, `TMPDIR` from
1313            // `tempfile::tempdir()` in this crate's own graph tests), and
1314            // no lock we hold makes them wait. That residual risk is why
1315            // these calls remain `unsafe` rather than being wrapped away,
1316            // and it is the trade-off a test suite that must exercise
1317            // `env()` against the real environment accepts.
1318            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                // SAFETY: see `EnvVarGuard::hold`. Still under `ENV_LOCK`,
1336                // which this guard releases only once it has been dropped.
1337                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(); // test helper: parse + evaluate, single file
1352        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    /// The other half of `env()`'s load-time behaviour: when the variable
1358    /// *is* set, its value wins over the default.
1359    #[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        // no `profil` in scope, nothing close: no help
1379        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    /// A beam parameter that shares a name with a file-level `let` shadows
1395    /// it (matching `Scope::with_params`'s documented shadowing), so
1396    /// load-time validation of a deferred `run` template must treat that
1397    /// name as unknown-until-schedule-time, not eagerly evaluate it using
1398    /// the (shadowed) `let`'s value. Concretely: `target` here is a
1399    /// `bool` `let`, but the beam's own `target` parameter shadows it and
1400    /// is always a string at schedule time, so `target + '!'` — string
1401    /// concatenation — must not be rejected as "`+` requires two
1402    /// strings, found a boolean and a string" at load time.
1403    #[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    /// The branch a statically-known `if` condition
1416    /// does *not* take must still be name-validated at load time — a
1417    /// typo hiding there must not depend on which machine (or which
1418    /// environment variable) the file happens to be loaded on.
1419    #[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    /// A beam parameter is forbidden in load-time
1433    /// fields (`cwd` here) even when a same-named file-level `let`
1434    /// exists — the `let` must not silently stand in for it.
1435    #[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    /// `env()` inside a deferred (`run`) template must
1449    /// not actually be executed at load time — only its name/arity are
1450    /// checked. A project with a beam referencing an unset environment
1451    /// variable in `run` must still load successfully; the "not set"
1452    /// failure belongs at schedule time, for whoever actually runs that
1453    /// beam.
1454    #[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    /// A load-time field is validated in full, not just along the branch a
1462    /// statically known condition happens to take. Which branch that is can
1463    /// depend on the environment the file is loaded in, so a typo hiding in
1464    /// the other one must not be reported on one machine and swallowed on
1465    /// the next.
1466    #[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    /// The same rule for an unknown function hiding in an untaken branch of
1480    /// a load-time field.
1481    #[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    /// A `let` binding is a load-time expression too, so its untaken
1494    /// branches are validated the same way.
1495    #[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    /// `env()` without a default is only resolvable when a beam runs, so a
1510    /// load-time field must reject it at the call site rather than making
1511    /// the whole project's load depend on the ambient environment.
1512    #[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    /// The same rule for a `let` binding, whatever the ambient environment
1521    /// holds — this is what makes `alba check` answer the same way on a
1522    /// laptop and in CI.
1523    #[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    /// `glob`'s arity is checked before its arguments are resolved, so a
1536    /// miscall is reported even when one argument only becomes known once
1537    /// a beam parameter is bound — matching how `env`'s arity is checked.
1538    #[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    /// `inputs` patterns are matched with `globset` at run time, so they
1575    /// are compiled with `globset` here — a pattern that only fails there
1576    /// used to match nothing at all, which the cache read as a beam whose
1577    /// inputs never change.
1578    #[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    /// Only a pattern's leading `.` and `..` components lead somewhere
1594    /// (see `anchor_pattern`): one further in is compared as a literal
1595    /// directory name, which no walked path ever holds, so the pattern
1596    /// would silently match nothing.
1597    #[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    /// The `glob()` built-in and the `inputs`/`outputs` fields validate
1636    /// against two different, incompatible dialects — `glob::Pattern` for
1637    /// the former, `globset` for the latter, each being what actually
1638    /// consumes the pattern later. `src**/x.rs` is legal in one and not
1639    /// the other, and neither check may be swapped for the other's
1640    /// dialect: doing so would reject patterns the matcher accepts, or
1641    /// accept patterns it cannot compile.
1642    #[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    /// A name close to `docker` (a plausible typo) is not special-cased:
1804    /// the load-time typo suggestion moved to plan time, so any name other
1805    /// than `shell`/`system_shell`/`docker` becomes a plugin reference,
1806    /// however near it reads to a known one.
1807    #[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    /// Same rule as `an_executor_name_near_docker_still_becomes_a_plugin_reference`,
1836    /// for a typo of `system_shell`.
1837    #[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    /// `suggest`'s candidates commonly come from
1862    /// `HashMap` iteration (`Scope::names`), whose order is randomized
1863    /// per process — without an explicit tie-break, two equally-close
1864    /// candidates could yield a different suggestion from run to run.
1865    #[test]
1866    fn suggest_breaks_distance_ties_alphabetically() {
1867        // "hat" is Levenshtein distance 1 from both "cat" and "bat".
1868        assert_eq!(suggest("hat", ["cat", "bat"].into_iter()), Some("bat"));
1869        assert_eq!(suggest("hat", ["bat", "cat"].into_iter()), Some("bat"));
1870    }
1871
1872    /// `load_str` used to silently drop `import`
1873    /// declarations (it has no filesystem to resolve them against), which
1874    /// would let a source string containing one "succeed" with beams
1875    /// quietly missing. It must fail loudly instead.
1876    #[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}