Skip to main content

alba_syntax/
parser.rs

1//! Recursive descent parser turning a token stream into a [`crate::ast::File`].
2//!
3//! Grammar notes not obvious from the AST shapes alone:
4//!
5//! - The Beamfile DSL is brace/bracket/paren-delimited with no significant
6//!   whitespace. `Newline` tokens from the lexer are
7//!   therefore dropped up front, before the parser ever sees them: they
8//!   carry no grammatical meaning here, so a `needs` list (or any other
9//!   list) may freely span multiple lines, and beam fields are separated
10//!   purely by the fact that each one starts with a recognizable field
11//!   keyword-ish identifier.
12//! - A field may appear at most once per `beam { ... }` block; a repeat is
13//!   a parse error (`duplicate field`). Not spelled out by the grammar
14//!   example, but the safer reading: silently accepting (and presumably
15//!   overwriting or merging) a repeated field is far more likely to hide a
16//!   copy-paste mistake than to be an intentional pattern.
17//! - `needs [...]`/`inputs [...]`/`outputs [...]`/list-form `run [...]`
18//!   accept an optional trailing comma before `]`.
19//! - `env { NAME = value }` values and `executor <name> { option value }`
20//!   option values follow the same "keyword-ish identifier followed by its
21//!   value" shape as beam fields (`image "deployer:latest"`, no `=`). `env`
22//!   values may be a string literal (a `StringTemplate`) or a bare
23//!   identifier (a reference to a `let` binding or a beam parameter, as in
24//!   `env { DEPLOY_TARGET = target }`); the bare-identifier form is
25//!   modeled as a single-part template wrapping that variable reference —
26//!   equivalent to writing `"{target}"` — so `NamedString`'s value half
27//!   stays uniformly a `StringTemplate` either way. An executor option's
28//!   value is never a bare identifier, but it is one of three shapes,
29//!   dispatched on its first token: `true`/`false` for a bool, `[...]` for
30//!   a list of string literals, anything else parsed as a string literal
31//!   (`ExecutorOptionValue`). Like a beam field, an executor option key may
32//!   appear at most once per `executor <name> { ... }` block; a repeat is
33//!   a parse error (`duplicate executor option`), for the same reason.
34
35use crate::ast::{
36    BeamDecl, BeamRef, ExecutorDecl, ExecutorOptionValue, File, HookDecl, Import, LetBinding,
37    NamedString, Spanned,
38};
39use crate::expr::Expr;
40use crate::lexer::Lexer;
41use crate::template::{StringTemplate, TemplatePart};
42use crate::token::{Span, Token, TokenKind};
43use std::collections::HashSet;
44
45/// The field names recognized inside a `beam { ... }` block.
46const BEAM_FIELDS: &[&str] = &[
47    "description",
48    "needs",
49    "inputs",
50    "outputs",
51    "run",
52    "env",
53    "cwd",
54    "executor",
55    "allow_failure",
56];
57
58/// An error produced while parsing a token stream (including lexing
59/// errors, surfaced through the same type since callers only ever see
60/// [`parse`]'s single `Result`).
61#[derive(Debug, Clone, PartialEq)]
62pub struct ParseError {
63    pub message: String,
64    pub span: Span,
65    pub help: Option<String>,
66}
67
68/// Parses a whole Beamfile from source text into a typed [`File`].
69pub fn parse(source: &str) -> Result<File, ParseError> {
70    let tokens = tokenize(source)?;
71    Parser::new(tokens, source).parse_file()
72}
73
74/// Lexes `source` into a flat token list, dropping `Newline` tokens (see
75/// the module doc comment) and stopping at the first lex error.
76///
77/// `pub(crate)` so `expr.rs`'s tests and `template.rs`'s interpolation
78/// handling can tokenize source text the same way the top-level parser
79/// does.
80pub(crate) fn tokenize(source: &str) -> Result<Vec<Token>, ParseError> {
81    let mut tokens = Vec::new();
82    for result in Lexer::new(source) {
83        match result {
84            Ok(token) => {
85                if token.kind != TokenKind::Newline {
86                    tokens.push(token);
87                }
88            }
89            Err(err) => {
90                return Err(ParseError {
91                    message: err.message,
92                    span: err.span,
93                    help: None,
94                });
95            }
96        }
97    }
98    Ok(tokens)
99}
100
101/// Per-beam field accumulator, threaded through [`Parser::parse_beam_field`]
102/// so that function doesn't need ten separate `&mut` parameters.
103#[derive(Default)]
104struct BeamFields {
105    description: Option<StringTemplate>,
106    needs: Vec<Spanned<BeamRef>>,
107    inputs: Vec<StringTemplate>,
108    outputs: Vec<StringTemplate>,
109    run: Vec<StringTemplate>,
110    env: Vec<NamedString>,
111    cwd: Option<StringTemplate>,
112    executor: Option<ExecutorDecl>,
113    allow_failure: bool,
114    seen: HashSet<String>,
115}
116
117/// Recursive descent parser over a flat token stream, plus (in `expr.rs`,
118/// an `impl` block for this same type) the precedence-climbing expression
119/// parser. Keeps `source` alongside the tokens because interpolation
120/// (`template.rs`) needs to re-slice and re-lex the *original* source text
121/// to keep its spans accurate; see `template.rs`'s module doc comment.
122pub(crate) struct Parser<'a> {
123    tokens: Vec<Token>,
124    source: &'a str,
125    pos: usize,
126}
127
128impl<'a> Parser<'a> {
129    pub(crate) fn new(tokens: Vec<Token>, source: &'a str) -> Self {
130        Self {
131            tokens,
132            source,
133            pos: 0,
134        }
135    }
136
137    /// The full original source text this parser's tokens were lexed from,
138    /// for slicing out the raw text behind a token's span.
139    pub(crate) fn source(&self) -> &'a str {
140        self.source
141    }
142
143    pub(crate) fn peek(&self) -> &Token {
144        &self.tokens[self.pos]
145    }
146
147    pub(crate) fn is_eof(&self) -> bool {
148        matches!(self.peek().kind, TokenKind::Eof)
149    }
150
151    /// Consumes and returns the current token. Never advances past the
152    /// trailing `Eof`, so repeated calls once at end of input keep
153    /// returning `Eof` rather than panicking.
154    pub(crate) fn advance(&mut self) -> Token {
155        let tok = self.tokens[self.pos].clone();
156        if self.pos + 1 < self.tokens.len() {
157            self.pos += 1;
158        }
159        tok
160    }
161
162    pub(crate) fn check(&self, kind: &TokenKind) -> bool {
163        &self.peek().kind == kind
164    }
165
166    pub(crate) fn expect(&mut self, kind: TokenKind, what: &str) -> Result<Token, ParseError> {
167        if self.check(&kind) {
168            Ok(self.advance())
169        } else {
170            Err(self.unexpected(what))
171        }
172    }
173
174    pub(crate) fn unexpected(&self, expected: &str) -> ParseError {
175        let tok = self.peek();
176        ParseError {
177            message: format!("expected {expected}, found {}", describe(&tok.kind)),
178            span: tok.span,
179            help: None,
180        }
181    }
182
183    pub(crate) fn eat_ident(&mut self) -> Result<Spanned<String>, ParseError> {
184        match self.peek().kind.clone() {
185            TokenKind::Ident(name) => {
186                let span = self.advance().span;
187                Ok(Spanned::new(name, span))
188            }
189            _ => Err(self.unexpected("an identifier")),
190        }
191    }
192
193    fn eat_str(&mut self) -> Result<Spanned<String>, ParseError> {
194        match self.peek().kind.clone() {
195            TokenKind::Str(s) => {
196                let span = self.advance().span;
197                Ok(Spanned::new(s, span))
198            }
199            _ => Err(self.unexpected("a string literal")),
200        }
201    }
202
203    /// Consumes a string literal and parses it into a [`StringTemplate`],
204    /// resolving `{expr}` interpolation. Used for every AST field that
205    /// holds interpolatable text (as opposed to `eat_str`, still used for
206    /// plain identifiers-as-text like `import` paths and `version`).
207    pub(crate) fn eat_template(&mut self) -> Result<StringTemplate, ParseError> {
208        if matches!(self.peek().kind, TokenKind::Str(_)) {
209            let tok = self.advance();
210            crate::template::parse_template_at(self.source, tok.span)
211        } else {
212            Err(self.unexpected("a string literal"))
213        }
214    }
215
216    /// Parses a `[item, item, ...]` list, with `parse_item` consuming a
217    /// single element. Accepts an optional trailing comma.
218    fn parse_bracketed_list<T>(
219        &mut self,
220        mut parse_item: impl FnMut(&mut Self) -> Result<T, ParseError>,
221    ) -> Result<Vec<T>, ParseError> {
222        self.expect(TokenKind::LBracket, "`[`")?;
223        let mut items = Vec::new();
224        if !self.check(&TokenKind::RBracket) {
225            loop {
226                items.push(parse_item(self)?);
227                if self.check(&TokenKind::Comma) {
228                    self.advance();
229                    if self.check(&TokenKind::RBracket) {
230                        break; // trailing comma
231                    }
232                } else {
233                    break;
234                }
235            }
236        }
237        self.expect(TokenKind::RBracket, "`]`")?;
238        Ok(items)
239    }
240
241    fn parse_template_list(&mut self) -> Result<Vec<StringTemplate>, ParseError> {
242        self.parse_bracketed_list(Self::eat_template)
243    }
244
245    /// `codegen`, `api:build`, or `api:db:migrate`: as many `alias:`
246    /// segments as the reference spells, since a beam reached through a
247    /// chain of imports has one id segment per alias in that chain.
248    fn parse_beam_ref(&mut self) -> Result<Spanned<BeamRef>, ParseError> {
249        let mut segments = vec![self.eat_ident()?];
250        while self.check(&TokenKind::Colon) {
251            self.advance();
252            segments.push(self.eat_ident()?);
253        }
254
255        let span = Span::new(
256            segments[0].span.start,
257            segments[segments.len() - 1].span.end,
258        );
259        let name = segments.pop().expect("at least one segment was parsed");
260        Ok(Spanned::new(
261            BeamRef {
262                namespace: segments.into_iter().map(|s| s.value).collect(),
263                name: name.value,
264            },
265            span,
266        ))
267    }
268
269    fn parse_needs_list(&mut self) -> Result<Vec<Spanned<BeamRef>>, ParseError> {
270        self.parse_bracketed_list(Self::parse_beam_ref)
271    }
272
273    /// `run "cmd"` (single command) or `run ["cmd", "cmd"]` (sequential list).
274    fn parse_run_value(&mut self) -> Result<Vec<StringTemplate>, ParseError> {
275        if self.check(&TokenKind::LBracket) {
276            self.parse_template_list()
277        } else {
278            Ok(vec![self.eat_template()?])
279        }
280    }
281
282    fn parse_bool_value(&mut self) -> Result<bool, ParseError> {
283        match self.peek().kind {
284            TokenKind::KwTrue => {
285                self.advance();
286                Ok(true)
287            }
288            TokenKind::KwFalse => {
289                self.advance();
290                Ok(false)
291            }
292            _ => Err(self.unexpected("`true` or `false`")),
293        }
294    }
295
296    /// A string literal (parsed as a [`StringTemplate`]) or a bare
297    /// identifier — a variable reference, modeled as a single-part
298    /// template wrapping that reference. See the module doc comment.
299    fn parse_env_value(&mut self) -> Result<StringTemplate, ParseError> {
300        match self.peek().kind.clone() {
301            TokenKind::Str(_) => self.eat_template(),
302            TokenKind::Ident(name) => {
303                let span = self.advance().span;
304                Ok(StringTemplate {
305                    parts: vec![TemplatePart::Expr(Expr::Var(Spanned::new(name, span)))],
306                    span,
307                })
308            }
309            _ => Err(self.unexpected("a string or an identifier")),
310        }
311    }
312
313    fn parse_env_block(&mut self) -> Result<Vec<NamedString>, ParseError> {
314        self.expect(TokenKind::LBrace, "`{`")?;
315        let mut entries = Vec::new();
316        while !self.check(&TokenKind::RBrace) {
317            let key = self.eat_ident()?;
318            self.expect(TokenKind::Eq, "`=`")?;
319            let value = self.parse_env_value()?;
320            entries.push((key, value));
321            if self.check(&TokenKind::Comma) {
322                self.advance();
323            }
324        }
325        self.expect(TokenKind::RBrace, "`}`")?;
326        Ok(entries)
327    }
328
329    fn parse_executor_decl(&mut self) -> Result<ExecutorDecl, ParseError> {
330        let name = self.eat_ident()?;
331        // The `{ ... }` options block is optional: executors that take no
332        // options (`shell`, `system_shell`) can be declared as a bare name.
333        // Which names actually accept no options is `alba-core`'s concern,
334        // not the parser's.
335        if !self.check(&TokenKind::LBrace) {
336            return Ok(ExecutorDecl {
337                name,
338                options: Vec::new(),
339            });
340        }
341        self.advance();
342        let mut options = Vec::new();
343        // Same rule as `parse_beam_field`'s `seen` set, for the same
344        // reason: a repeated option key is far more likely to be a
345        // copy-paste mistake than an intentional overwrite, and silently
346        // keeping only the last value (as `serde_json::Map`'s `FromIterator`
347        // would once this reaches `alba-engine`) would hide exactly that
348        // mistake.
349        let mut seen = HashSet::new();
350        while !self.check(&TokenKind::RBrace) {
351            let opt_name = self.eat_ident()?;
352            if !seen.insert(opt_name.value.clone()) {
353                return Err(ParseError {
354                    message: format!("duplicate executor option `{}`", opt_name.value),
355                    span: opt_name.span,
356                    help: None,
357                });
358            }
359            let opt_value = match self.peek().kind {
360                TokenKind::KwTrue | TokenKind::KwFalse => {
361                    ExecutorOptionValue::Bool(self.parse_bool_value()?)
362                }
363                TokenKind::LBracket => ExecutorOptionValue::List(self.parse_template_list()?),
364                _ => ExecutorOptionValue::Str(self.eat_template()?),
365            };
366            options.push((opt_name, opt_value));
367        }
368        self.expect(TokenKind::RBrace, "`}`")?;
369        Ok(ExecutorDecl { name, options })
370    }
371
372    /// Parses a single `field value` pair inside a `beam { ... }` block,
373    /// dispatching on the field's identifier and folding the result into
374    /// `fields`. Rejects unknown field names (with a Levenshtein-distance
375    /// suggestion) and repeated fields.
376    fn parse_beam_field(&mut self, fields: &mut BeamFields) -> Result<(), ParseError> {
377        let field_tok = self.eat_ident()?;
378        let name = field_tok.value.as_str();
379
380        if !BEAM_FIELDS.contains(&name) {
381            let help = suggest_field(name).map(|f| format!("did you mean `{f}`?"));
382            return Err(ParseError {
383                message: format!("unknown field `{name}`"),
384                span: field_tok.span,
385                help,
386            });
387        }
388
389        if !fields.seen.insert(name.to_string()) {
390            return Err(ParseError {
391                message: format!("duplicate field `{name}`"),
392                span: field_tok.span,
393                help: None,
394            });
395        }
396
397        match name {
398            "description" => fields.description = Some(self.eat_template()?),
399            "needs" => fields.needs = self.parse_needs_list()?,
400            "inputs" => fields.inputs = self.parse_template_list()?,
401            "outputs" => fields.outputs = self.parse_template_list()?,
402            "run" => fields.run = self.parse_run_value()?,
403            "env" => fields.env = self.parse_env_block()?,
404            "cwd" => fields.cwd = Some(self.eat_template()?),
405            "executor" => fields.executor = Some(self.parse_executor_decl()?),
406            "allow_failure" => fields.allow_failure = self.parse_bool_value()?,
407            other => unreachable!("field `{other}` accepted by BEAM_FIELDS but not dispatched"),
408        }
409        Ok(())
410    }
411
412    /// `beam name(params) { field field ... }`.
413    fn parse_beam(&mut self) -> Result<BeamDecl, ParseError> {
414        let beam_kw = self.expect(TokenKind::KwBeam, "`beam`")?;
415        let name = self.eat_ident()?;
416
417        let mut params = Vec::new();
418        if self.check(&TokenKind::LParen) {
419            self.advance();
420            if !self.check(&TokenKind::RParen) {
421                loop {
422                    let param = self.eat_ident()?;
423                    reject_reserved(&param)?;
424                    params.push(param);
425                    if self.check(&TokenKind::Comma) {
426                        self.advance();
427                    } else {
428                        break;
429                    }
430                }
431            }
432            self.expect(TokenKind::RParen, "`)`")?;
433        }
434
435        self.expect(TokenKind::LBrace, "`{`")?;
436        let mut fields = BeamFields::default();
437        while !self.check(&TokenKind::RBrace) {
438            self.parse_beam_field(&mut fields)?;
439        }
440        let rbrace = self.expect(TokenKind::RBrace, "`}`")?;
441
442        Ok(BeamDecl {
443            name,
444            params,
445            description: fields.description,
446            needs: fields.needs,
447            inputs: fields.inputs,
448            outputs: fields.outputs,
449            run: fields.run,
450            env: fields.env,
451            cwd: fields.cwd,
452            executor: fields.executor,
453            allow_failure: fields.allow_failure,
454            span: Span::new(beam_kw.span.start, rbrace.span.end),
455        })
456    }
457
458    /// `hook <name> { beam <ref> }`. `beam` is the only field and is
459    /// required; the block form is kept so a later field (a `when`
460    /// condition, say) can join without breaking existing Beamfiles.
461    ///
462    /// The field's own name, `beam`, is the `KwBeam` keyword token (the
463    /// same one a top-level `beam` declaration starts with), not an
464    /// `Ident("beam")` — the lexer never produces the latter — so this
465    /// checks for the keyword directly rather than eating an identifier
466    /// and comparing its text.
467    fn parse_hook(&mut self) -> Result<HookDecl, ParseError> {
468        let hook_kw = self.expect(TokenKind::KwHook, "`hook`")?;
469        let name = self.eat_ident()?;
470        self.expect(TokenKind::LBrace, "`{`")?;
471        let mut beam = None;
472        while !self.check(&TokenKind::RBrace) {
473            if !self.check(&TokenKind::KwBeam) {
474                let field = self.eat_ident()?;
475                return Err(ParseError {
476                    message: format!("unknown hook field `{}`", field.value),
477                    span: field.span,
478                    help: Some("a hook has exactly one field, `beam`".to_string()),
479                });
480            }
481            let beam_span = self.advance().span;
482            if beam.is_some() {
483                return Err(ParseError {
484                    message: "duplicate field `beam`".to_string(),
485                    span: beam_span,
486                    help: None,
487                });
488            }
489            beam = Some(self.parse_beam_ref()?);
490        }
491        let rbrace = self.expect(TokenKind::RBrace, "`}`")?;
492        let Some(beam) = beam else {
493            return Err(ParseError {
494                message: format!("hook `{}` declares no `beam`", name.value),
495                span: name.span,
496                help: Some("write `beam <name>` inside the block".to_string()),
497            });
498        };
499        Ok(HookDecl {
500            name,
501            beam,
502            span: Span::new(hook_kw.span.start, rbrace.span.end),
503        })
504    }
505
506    /// `import "path" as alias`.
507    fn parse_import(&mut self) -> Result<Import, ParseError> {
508        self.expect(TokenKind::KwImport, "`import`")?;
509        let path = self.eat_str()?;
510        self.expect(TokenKind::KwAs, "`as`")?;
511        let alias = self.eat_ident()?;
512        Ok(Import { path, alias })
513    }
514
515    /// `let name = expr`.
516    fn parse_let(&mut self) -> Result<LetBinding, ParseError> {
517        self.expect(TokenKind::KwLet, "`let`")?;
518        let name = self.eat_ident()?;
519        reject_reserved(&name)?;
520        self.expect(TokenKind::Eq, "`=`")?;
521        let value = self.parse_expr()?;
522        Ok(LetBinding { name, value })
523    }
524
525    /// Parses a whole file: `version`, `import`s, `let`s, `default`, and
526    /// `beam` declarations, in any order, followed by end of input.
527    /// Detects duplicate beam names once every beam has been parsed.
528    fn parse_file(&mut self) -> Result<File, ParseError> {
529        let mut version = None;
530        let mut imports = Vec::new();
531        let mut lets = Vec::new();
532        let mut default = None;
533        let mut beams = Vec::new();
534        let mut hooks = Vec::new();
535
536        while !self.is_eof() {
537            match self.peek().kind {
538                TokenKind::KwVersion => {
539                    self.advance();
540                    version = Some(self.eat_str()?);
541                }
542                TokenKind::KwImport => imports.push(self.parse_import()?),
543                TokenKind::KwLet => lets.push(self.parse_let()?),
544                TokenKind::KwDefault => {
545                    self.advance();
546                    default = Some(self.eat_ident()?);
547                }
548                TokenKind::KwBeam => beams.push(self.parse_beam()?),
549                TokenKind::KwHook => hooks.push(self.parse_hook()?),
550                _ => {
551                    return Err(
552                        self.unexpected("`version`, `import`, `let`, `default`, `beam`, or `hook`")
553                    );
554                }
555            }
556        }
557
558        let mut seen_beam_names = HashSet::new();
559        for beam in &beams {
560            if !seen_beam_names.insert(beam.name.value.clone()) {
561                return Err(ParseError {
562                    message: format!("duplicate beam `{}`", beam.name.value),
563                    span: beam.name.span,
564                    help: None,
565                });
566            }
567        }
568
569        Ok(File {
570            version,
571            imports,
572            lets,
573            default,
574            beams,
575            hooks,
576        })
577    }
578}
579
580/// `git` names the built-in git object; binding it would shadow every
581/// `git.*` field at once.
582fn reject_reserved(name: &Spanned<String>) -> Result<(), ParseError> {
583    if name.value == "git" {
584        return Err(ParseError {
585            message: "`git` is reserved for the built-in git object".to_string(),
586            span: name.span,
587            help: Some(
588                "pick another name; `git.branch`, `git.sha`, `git.short_sha`, and `git.dirty` \
589                 read from it"
590                    .to_string(),
591            ),
592        });
593    }
594    Ok(())
595}
596
597/// The [`describe`] text for [`TokenKind::Eof`], exposed so `template.rs`
598/// can recognize (and correct) it when its own synthetic end-of-body
599/// sentinel — which reuses `TokenKind::Eof` as the sub-parser's
600/// end-of-input marker, but really means "the interpolation's closing
601/// `}`" — surfaces in an error message. Kept as a single source of truth
602/// rather than a string duplicated in both places, so the two can't drift.
603pub(crate) const EOF_DESCRIPTION: &str = "end of input";
604
605/// A short, human-readable description of a token kind for error messages.
606fn describe(kind: &TokenKind) -> String {
607    match kind {
608        TokenKind::Ident(name) => format!("identifier `{name}`"),
609        TokenKind::Str(_) => "a string literal".to_string(),
610        TokenKind::KwVersion => "`version`".to_string(),
611        TokenKind::KwImport => "`import`".to_string(),
612        TokenKind::KwAs => "`as`".to_string(),
613        TokenKind::KwLet => "`let`".to_string(),
614        TokenKind::KwDefault => "`default`".to_string(),
615        TokenKind::KwBeam => "`beam`".to_string(),
616        TokenKind::KwIf => "`if`".to_string(),
617        TokenKind::KwThen => "`then`".to_string(),
618        TokenKind::KwElse => "`else`".to_string(),
619        TokenKind::KwTrue => "`true`".to_string(),
620        TokenKind::KwFalse => "`false`".to_string(),
621        TokenKind::KwHook => "`hook`".to_string(),
622        TokenKind::LBrace => "`{`".to_string(),
623        TokenKind::RBrace => "`}`".to_string(),
624        TokenKind::LBracket => "`[`".to_string(),
625        TokenKind::RBracket => "`]`".to_string(),
626        TokenKind::LParen => "`(`".to_string(),
627        TokenKind::RParen => "`)`".to_string(),
628        TokenKind::Comma => "`,`".to_string(),
629        TokenKind::Colon => "`:`".to_string(),
630        TokenKind::Eq => "`=`".to_string(),
631        TokenKind::EqEq => "`==`".to_string(),
632        TokenKind::NotEq => "`!=`".to_string(),
633        TokenKind::AndAnd => "`&&`".to_string(),
634        TokenKind::OrOr => "`||`".to_string(),
635        TokenKind::Plus => "`+`".to_string(),
636        TokenKind::Dot => "`.`".to_string(),
637        TokenKind::Newline => "a newline".to_string(),
638        TokenKind::Eof => EOF_DESCRIPTION.to_string(),
639    }
640}
641
642/// Suggests the closest known beam field name to `name`, if any is within
643/// Levenshtein distance 2 (picking the closest on ties by array order).
644fn suggest_field(name: &str) -> Option<&'static str> {
645    BEAM_FIELDS
646        .iter()
647        .map(|&field| (field, levenshtein(name, field)))
648        .filter(|&(_, distance)| distance <= 2)
649        .min_by_key(|&(_, distance)| distance)
650        .map(|(field, _)| field)
651}
652
653/// Classic dynamic-programming Levenshtein edit distance between two
654/// strings, operating on `char`s (not bytes) so it stays correct for
655/// non-ASCII field-name typos.
656fn levenshtein(a: &str, b: &str) -> usize {
657    let a: Vec<char> = a.chars().collect();
658    let b: Vec<char> = b.chars().collect();
659
660    let mut row: Vec<usize> = (0..=b.len()).collect();
661    for i in 1..=a.len() {
662        let mut prev_diag = row[0];
663        row[0] = i;
664        for j in 1..=b.len() {
665            let temp = row[j];
666            row[j] = if a[i - 1] == b[j - 1] {
667                prev_diag
668            } else {
669                1 + prev_diag.min(row[j]).min(row[j - 1])
670            };
671            prev_diag = temp;
672        }
673    }
674    row[b.len()]
675}
676
677#[cfg(test)]
678mod tests {
679    use crate::parse;
680
681    #[test]
682    fn parses_full_beamfile() {
683        let src = r#"
684version "1"
685import "api/Beamfile" as api
686let profile = env("PROFILE", "debug")
687default build
688
689beam build {
690  description "Compile"
691  needs [api:build, codegen]
692  inputs ["src/**/*.rs"]
693  run "cargo build"
694}
695
696beam deploy(target) {
697  needs [build]
698  allow_failure true
699  run ["echo one", "echo two"]
700}
701"#;
702        let file = parse(src).unwrap();
703        assert_eq!(file.beams.len(), 2);
704        assert_eq!(file.default.as_ref().unwrap().value, "build");
705        let build = &file.beams[0];
706        assert_eq!(build.needs[0].value.namespace, ["api"]);
707        let deploy = &file.beams[1];
708        assert_eq!(deploy.params[0].value, "target");
709        assert!(deploy.allow_failure);
710        assert_eq!(deploy.run.len(), 2);
711    }
712
713    #[test]
714    fn rejects_unknown_beam_field_with_help() {
715        let err = parse("beam x { descriptoin \"typo\" }").unwrap_err();
716        assert!(err.message.contains("unknown field"));
717        assert_eq!(err.help.as_deref(), Some("did you mean `description`?"));
718    }
719
720    #[test]
721    fn rejects_duplicate_beam_names() {
722        let err = parse("beam x { run \"a\" }\nbeam x { run \"b\" }").unwrap_err();
723        assert!(err.message.contains("duplicate beam"));
724    }
725
726    #[test]
727    fn rejects_duplicate_field_in_same_beam() {
728        let err = parse("beam x { run \"a\" run \"b\" }").unwrap_err();
729        assert!(err.message.contains("duplicate field"));
730    }
731
732    #[test]
733    fn needs_list_accepts_trailing_comma_and_newlines() {
734        let file = parse("beam x {\n  needs [\n    a,\n    b,\n  ]\n}").unwrap();
735        let needs = &file.beams[0].needs;
736        assert_eq!(needs.len(), 2);
737        assert_eq!(needs[0].value.name, "a");
738        assert_eq!(needs[1].value.name, "b");
739    }
740
741    /// A beam reached through a chain of import aliases has a
742    /// multi-segment id (`api:db:migrate`), and `needs` must be able to
743    /// spell it — otherwise an id Alba creates, lists, and accepts as a
744    /// run target cannot be depended on.
745    #[test]
746    fn needs_accepts_a_multi_segment_namespace() {
747        let file = parse("beam x { needs [api:db:migrate] }").unwrap();
748
749        let entry = &file.beams[0].needs[0];
750        assert_eq!(entry.value.namespace, ["api", "db"]);
751        assert_eq!(entry.value.name, "migrate");
752        assert_eq!(
753            &"beam x { needs [api:db:migrate] }"[entry.span.start..entry.span.end],
754            "api:db:migrate"
755        );
756    }
757
758    #[test]
759    fn parses_let_with_equality_expression() {
760        let file = parse(r#"let release = profile == "release""#).unwrap();
761        assert_eq!(file.lets.len(), 1);
762        assert_eq!(file.lets[0].name.value, "release");
763    }
764
765    #[test]
766    fn parses_env_and_executor_blocks() {
767        let file = parse(
768            r#"beam deploy(target) {
769  executor docker { image "deployer:latest" }
770  env { DEPLOY_TARGET = target }
771  run "./scripts/deploy.sh {target}"
772}"#,
773        )
774        .unwrap();
775        let beam = &file.beams[0];
776        let executor = beam.executor.as_ref().unwrap();
777        assert_eq!(executor.name.value, "docker");
778        assert_eq!(executor.options[0].0.value, "image");
779        assert!(matches!(
780            &executor.options[0].1,
781            crate::ast::ExecutorOptionValue::Str(t)
782                if t.parts == vec![crate::TemplatePart::Literal("deployer:latest".to_string())]
783        ));
784        assert_eq!(beam.env[0].0.value, "DEPLOY_TARGET");
785        // `target` is a bare identifier (no quotes), so it becomes a
786        // single-part template wrapping a variable reference, equivalent
787        // to writing `"{target}"`.
788        assert!(matches!(
789            &beam.env[0].1.parts[..],
790            [crate::TemplatePart::Expr(crate::Expr::Var(v))] if v.value == "target"
791        ));
792    }
793
794    /// `executor system_shell` and `executor shell` name an executor that
795    /// takes no options, so the `{ ... }` block is optional. Which names
796    /// are actually valid executors is `alba-core`'s concern, not the
797    /// parser's — the parser only decides whether a block follows.
798    #[test]
799    fn executor_without_a_block_parses() {
800        let file = parse("beam b {\n  executor system_shell\n  run \"x\"\n}").unwrap();
801        let executor = file.beams[0].executor.as_ref().unwrap();
802        assert_eq!(executor.name.value, "system_shell");
803        assert!(executor.options.is_empty());
804    }
805
806    #[test]
807    fn executor_with_a_block_still_parses() {
808        let file = parse("beam b {\n  executor docker { image \"i\" }\n  run \"x\"\n}").unwrap();
809        let executor = file.beams[0].executor.as_ref().unwrap();
810        assert_eq!(executor.name.value, "docker");
811        assert_eq!(executor.options[0].0.value, "image");
812    }
813
814    #[test]
815    fn reports_unexpected_token_span_not_enclosing_construct() {
816        let err = parse("beam x { needs [a, }").unwrap_err();
817        // The span must point at the offending `}`, not at `needs` or `beam`.
818        assert_eq!(err.span, crate::Span::new(19, 20));
819    }
820}