alba-syntax 0.2.0

Lexer, parser, AST, and diagnostics for the Beamfile DSL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
//! Recursive descent parser turning a token stream into a [`crate::ast::File`].
//!
//! Grammar notes not obvious from the AST shapes alone:
//!
//! - The Beamfile DSL is brace/bracket/paren-delimited with no significant
//!   whitespace. `Newline` tokens from the lexer are
//!   therefore dropped up front, before the parser ever sees them: they
//!   carry no grammatical meaning here, so a `needs` list (or any other
//!   list) may freely span multiple lines, and beam fields are separated
//!   purely by the fact that each one starts with a recognizable field
//!   keyword-ish identifier.
//! - A field may appear at most once per `beam { ... }` block; a repeat is
//!   a parse error (`duplicate field`). Not spelled out by the grammar
//!   example, but the safer reading: silently accepting (and presumably
//!   overwriting or merging) a repeated field is far more likely to hide a
//!   copy-paste mistake than to be an intentional pattern.
//! - `needs [...]`/`inputs [...]`/`outputs [...]`/list-form `run [...]`
//!   accept an optional trailing comma before `]`.
//! - `env { NAME = value }` values and `executor <name> { option value }`
//!   option values follow the same "keyword-ish identifier followed by its
//!   value" shape as beam fields (`image "deployer:latest"`, no `=`). `env`
//!   values may be a string literal (a `StringTemplate`) or a bare
//!   identifier (a reference to a `let` binding or a beam parameter, as in
//!   `env { DEPLOY_TARGET = target }`); the bare-identifier form is
//!   modeled as a single-part template wrapping that variable reference —
//!   equivalent to writing `"{target}"` — so `NamedString`'s value half
//!   stays uniformly a `StringTemplate` either way. An executor option's
//!   value is never a bare identifier, but it is one of three shapes,
//!   dispatched on its first token: `true`/`false` for a bool, `[...]` for
//!   a list of string literals, anything else parsed as a string literal
//!   (`ExecutorOptionValue`). Like a beam field, an executor option key may
//!   appear at most once per `executor <name> { ... }` block; a repeat is
//!   a parse error (`duplicate executor option`), for the same reason.

use crate::ast::{
    BeamDecl, BeamRef, ExecutorDecl, ExecutorOptionValue, File, HookDecl, Import, LetBinding,
    NamedString, Spanned,
};
use crate::expr::Expr;
use crate::lexer::Lexer;
use crate::template::{StringTemplate, TemplatePart};
use crate::token::{Span, Token, TokenKind};
use std::collections::HashSet;

/// The field names recognized inside a `beam { ... }` block.
const BEAM_FIELDS: &[&str] = &[
    "description",
    "needs",
    "inputs",
    "outputs",
    "run",
    "env",
    "cwd",
    "executor",
    "allow_failure",
];

/// An error produced while parsing a token stream (including lexing
/// errors, surfaced through the same type since callers only ever see
/// [`parse`]'s single `Result`).
#[derive(Debug, Clone, PartialEq)]
pub struct ParseError {
    pub message: String,
    pub span: Span,
    pub help: Option<String>,
}

/// Parses a whole Beamfile from source text into a typed [`File`].
pub fn parse(source: &str) -> Result<File, ParseError> {
    let tokens = tokenize(source)?;
    Parser::new(tokens, source).parse_file()
}

/// Lexes `source` into a flat token list, dropping `Newline` tokens (see
/// the module doc comment) and stopping at the first lex error.
///
/// `pub(crate)` so `expr.rs`'s tests and `template.rs`'s interpolation
/// handling can tokenize source text the same way the top-level parser
/// does.
pub(crate) fn tokenize(source: &str) -> Result<Vec<Token>, ParseError> {
    let mut tokens = Vec::new();
    for result in Lexer::new(source) {
        match result {
            Ok(token) => {
                if token.kind != TokenKind::Newline {
                    tokens.push(token);
                }
            }
            Err(err) => {
                return Err(ParseError {
                    message: err.message,
                    span: err.span,
                    help: None,
                });
            }
        }
    }
    Ok(tokens)
}

/// Per-beam field accumulator, threaded through [`Parser::parse_beam_field`]
/// so that function doesn't need ten separate `&mut` parameters.
#[derive(Default)]
struct BeamFields {
    description: Option<StringTemplate>,
    needs: Vec<Spanned<BeamRef>>,
    inputs: Vec<StringTemplate>,
    outputs: Vec<StringTemplate>,
    run: Vec<StringTemplate>,
    env: Vec<NamedString>,
    cwd: Option<StringTemplate>,
    executor: Option<ExecutorDecl>,
    allow_failure: bool,
    seen: HashSet<String>,
}

/// Recursive descent parser over a flat token stream, plus (in `expr.rs`,
/// an `impl` block for this same type) the precedence-climbing expression
/// parser. Keeps `source` alongside the tokens because interpolation
/// (`template.rs`) needs to re-slice and re-lex the *original* source text
/// to keep its spans accurate; see `template.rs`'s module doc comment.
pub(crate) struct Parser<'a> {
    tokens: Vec<Token>,
    source: &'a str,
    pos: usize,
}

impl<'a> Parser<'a> {
    pub(crate) fn new(tokens: Vec<Token>, source: &'a str) -> Self {
        Self {
            tokens,
            source,
            pos: 0,
        }
    }

    /// The full original source text this parser's tokens were lexed from,
    /// for slicing out the raw text behind a token's span.
    pub(crate) fn source(&self) -> &'a str {
        self.source
    }

    pub(crate) fn peek(&self) -> &Token {
        &self.tokens[self.pos]
    }

    pub(crate) fn is_eof(&self) -> bool {
        matches!(self.peek().kind, TokenKind::Eof)
    }

    /// Consumes and returns the current token. Never advances past the
    /// trailing `Eof`, so repeated calls once at end of input keep
    /// returning `Eof` rather than panicking.
    pub(crate) fn advance(&mut self) -> Token {
        let tok = self.tokens[self.pos].clone();
        if self.pos + 1 < self.tokens.len() {
            self.pos += 1;
        }
        tok
    }

    pub(crate) fn check(&self, kind: &TokenKind) -> bool {
        &self.peek().kind == kind
    }

    pub(crate) fn expect(&mut self, kind: TokenKind, what: &str) -> Result<Token, ParseError> {
        if self.check(&kind) {
            Ok(self.advance())
        } else {
            Err(self.unexpected(what))
        }
    }

    pub(crate) fn unexpected(&self, expected: &str) -> ParseError {
        let tok = self.peek();
        ParseError {
            message: format!("expected {expected}, found {}", describe(&tok.kind)),
            span: tok.span,
            help: None,
        }
    }

    pub(crate) fn eat_ident(&mut self) -> Result<Spanned<String>, ParseError> {
        match self.peek().kind.clone() {
            TokenKind::Ident(name) => {
                let span = self.advance().span;
                Ok(Spanned::new(name, span))
            }
            _ => Err(self.unexpected("an identifier")),
        }
    }

    fn eat_str(&mut self) -> Result<Spanned<String>, ParseError> {
        match self.peek().kind.clone() {
            TokenKind::Str(s) => {
                let span = self.advance().span;
                Ok(Spanned::new(s, span))
            }
            _ => Err(self.unexpected("a string literal")),
        }
    }

    /// Consumes a string literal and parses it into a [`StringTemplate`],
    /// resolving `{expr}` interpolation. Used for every AST field that
    /// holds interpolatable text (as opposed to `eat_str`, still used for
    /// plain identifiers-as-text like `import` paths and `version`).
    pub(crate) fn eat_template(&mut self) -> Result<StringTemplate, ParseError> {
        if matches!(self.peek().kind, TokenKind::Str(_)) {
            let tok = self.advance();
            crate::template::parse_template_at(self.source, tok.span)
        } else {
            Err(self.unexpected("a string literal"))
        }
    }

    /// Parses a `[item, item, ...]` list, with `parse_item` consuming a
    /// single element. Accepts an optional trailing comma.
    fn parse_bracketed_list<T>(
        &mut self,
        mut parse_item: impl FnMut(&mut Self) -> Result<T, ParseError>,
    ) -> Result<Vec<T>, ParseError> {
        self.expect(TokenKind::LBracket, "`[`")?;
        let mut items = Vec::new();
        if !self.check(&TokenKind::RBracket) {
            loop {
                items.push(parse_item(self)?);
                if self.check(&TokenKind::Comma) {
                    self.advance();
                    if self.check(&TokenKind::RBracket) {
                        break; // trailing comma
                    }
                } else {
                    break;
                }
            }
        }
        self.expect(TokenKind::RBracket, "`]`")?;
        Ok(items)
    }

    fn parse_template_list(&mut self) -> Result<Vec<StringTemplate>, ParseError> {
        self.parse_bracketed_list(Self::eat_template)
    }

    /// `codegen`, `api:build`, or `api:db:migrate`: as many `alias:`
    /// segments as the reference spells, since a beam reached through a
    /// chain of imports has one id segment per alias in that chain.
    fn parse_beam_ref(&mut self) -> Result<Spanned<BeamRef>, ParseError> {
        let mut segments = vec![self.eat_ident()?];
        while self.check(&TokenKind::Colon) {
            self.advance();
            segments.push(self.eat_ident()?);
        }

        let span = Span::new(
            segments[0].span.start,
            segments[segments.len() - 1].span.end,
        );
        let name = segments.pop().expect("at least one segment was parsed");
        Ok(Spanned::new(
            BeamRef {
                namespace: segments.into_iter().map(|s| s.value).collect(),
                name: name.value,
            },
            span,
        ))
    }

    fn parse_needs_list(&mut self) -> Result<Vec<Spanned<BeamRef>>, ParseError> {
        self.parse_bracketed_list(Self::parse_beam_ref)
    }

    /// `run "cmd"` (single command) or `run ["cmd", "cmd"]` (sequential list).
    fn parse_run_value(&mut self) -> Result<Vec<StringTemplate>, ParseError> {
        if self.check(&TokenKind::LBracket) {
            self.parse_template_list()
        } else {
            Ok(vec![self.eat_template()?])
        }
    }

    fn parse_bool_value(&mut self) -> Result<bool, ParseError> {
        match self.peek().kind {
            TokenKind::KwTrue => {
                self.advance();
                Ok(true)
            }
            TokenKind::KwFalse => {
                self.advance();
                Ok(false)
            }
            _ => Err(self.unexpected("`true` or `false`")),
        }
    }

    /// A string literal (parsed as a [`StringTemplate`]) or a bare
    /// identifier — a variable reference, modeled as a single-part
    /// template wrapping that reference. See the module doc comment.
    fn parse_env_value(&mut self) -> Result<StringTemplate, ParseError> {
        match self.peek().kind.clone() {
            TokenKind::Str(_) => self.eat_template(),
            TokenKind::Ident(name) => {
                let span = self.advance().span;
                Ok(StringTemplate {
                    parts: vec![TemplatePart::Expr(Expr::Var(Spanned::new(name, span)))],
                    span,
                })
            }
            _ => Err(self.unexpected("a string or an identifier")),
        }
    }

    fn parse_env_block(&mut self) -> Result<Vec<NamedString>, ParseError> {
        self.expect(TokenKind::LBrace, "`{`")?;
        let mut entries = Vec::new();
        while !self.check(&TokenKind::RBrace) {
            let key = self.eat_ident()?;
            self.expect(TokenKind::Eq, "`=`")?;
            let value = self.parse_env_value()?;
            entries.push((key, value));
            if self.check(&TokenKind::Comma) {
                self.advance();
            }
        }
        self.expect(TokenKind::RBrace, "`}`")?;
        Ok(entries)
    }

    fn parse_executor_decl(&mut self) -> Result<ExecutorDecl, ParseError> {
        let name = self.eat_ident()?;
        // The `{ ... }` options block is optional: executors that take no
        // options (`shell`, `system_shell`) can be declared as a bare name.
        // Which names actually accept no options is `alba-core`'s concern,
        // not the parser's.
        if !self.check(&TokenKind::LBrace) {
            return Ok(ExecutorDecl {
                name,
                options: Vec::new(),
            });
        }
        self.advance();
        let mut options = Vec::new();
        // Same rule as `parse_beam_field`'s `seen` set, for the same
        // reason: a repeated option key is far more likely to be a
        // copy-paste mistake than an intentional overwrite, and silently
        // keeping only the last value (as `serde_json::Map`'s `FromIterator`
        // would once this reaches `alba-engine`) would hide exactly that
        // mistake.
        let mut seen = HashSet::new();
        while !self.check(&TokenKind::RBrace) {
            let opt_name = self.eat_ident()?;
            if !seen.insert(opt_name.value.clone()) {
                return Err(ParseError {
                    message: format!("duplicate executor option `{}`", opt_name.value),
                    span: opt_name.span,
                    help: None,
                });
            }
            let opt_value = match self.peek().kind {
                TokenKind::KwTrue | TokenKind::KwFalse => {
                    ExecutorOptionValue::Bool(self.parse_bool_value()?)
                }
                TokenKind::LBracket => ExecutorOptionValue::List(self.parse_template_list()?),
                _ => ExecutorOptionValue::Str(self.eat_template()?),
            };
            options.push((opt_name, opt_value));
        }
        self.expect(TokenKind::RBrace, "`}`")?;
        Ok(ExecutorDecl { name, options })
    }

    /// Parses a single `field value` pair inside a `beam { ... }` block,
    /// dispatching on the field's identifier and folding the result into
    /// `fields`. Rejects unknown field names (with a Levenshtein-distance
    /// suggestion) and repeated fields.
    fn parse_beam_field(&mut self, fields: &mut BeamFields) -> Result<(), ParseError> {
        let field_tok = self.eat_ident()?;
        let name = field_tok.value.as_str();

        if !BEAM_FIELDS.contains(&name) {
            let help = suggest_field(name).map(|f| format!("did you mean `{f}`?"));
            return Err(ParseError {
                message: format!("unknown field `{name}`"),
                span: field_tok.span,
                help,
            });
        }

        if !fields.seen.insert(name.to_string()) {
            return Err(ParseError {
                message: format!("duplicate field `{name}`"),
                span: field_tok.span,
                help: None,
            });
        }

        match name {
            "description" => fields.description = Some(self.eat_template()?),
            "needs" => fields.needs = self.parse_needs_list()?,
            "inputs" => fields.inputs = self.parse_template_list()?,
            "outputs" => fields.outputs = self.parse_template_list()?,
            "run" => fields.run = self.parse_run_value()?,
            "env" => fields.env = self.parse_env_block()?,
            "cwd" => fields.cwd = Some(self.eat_template()?),
            "executor" => fields.executor = Some(self.parse_executor_decl()?),
            "allow_failure" => fields.allow_failure = self.parse_bool_value()?,
            other => unreachable!("field `{other}` accepted by BEAM_FIELDS but not dispatched"),
        }
        Ok(())
    }

    /// `beam name(params) { field field ... }`.
    fn parse_beam(&mut self) -> Result<BeamDecl, ParseError> {
        let beam_kw = self.expect(TokenKind::KwBeam, "`beam`")?;
        let name = self.eat_ident()?;

        let mut params = Vec::new();
        if self.check(&TokenKind::LParen) {
            self.advance();
            if !self.check(&TokenKind::RParen) {
                loop {
                    let param = self.eat_ident()?;
                    reject_reserved(&param)?;
                    params.push(param);
                    if self.check(&TokenKind::Comma) {
                        self.advance();
                    } else {
                        break;
                    }
                }
            }
            self.expect(TokenKind::RParen, "`)`")?;
        }

        self.expect(TokenKind::LBrace, "`{`")?;
        let mut fields = BeamFields::default();
        while !self.check(&TokenKind::RBrace) {
            self.parse_beam_field(&mut fields)?;
        }
        let rbrace = self.expect(TokenKind::RBrace, "`}`")?;

        Ok(BeamDecl {
            name,
            params,
            description: fields.description,
            needs: fields.needs,
            inputs: fields.inputs,
            outputs: fields.outputs,
            run: fields.run,
            env: fields.env,
            cwd: fields.cwd,
            executor: fields.executor,
            allow_failure: fields.allow_failure,
            span: Span::new(beam_kw.span.start, rbrace.span.end),
        })
    }

    /// `hook <name> { beam <ref> }`. `beam` is the only field and is
    /// required; the block form is kept so a later field (a `when`
    /// condition, say) can join without breaking existing Beamfiles.
    ///
    /// The field's own name, `beam`, is the `KwBeam` keyword token (the
    /// same one a top-level `beam` declaration starts with), not an
    /// `Ident("beam")` — the lexer never produces the latter — so this
    /// checks for the keyword directly rather than eating an identifier
    /// and comparing its text.
    fn parse_hook(&mut self) -> Result<HookDecl, ParseError> {
        let hook_kw = self.expect(TokenKind::KwHook, "`hook`")?;
        let name = self.eat_ident()?;
        self.expect(TokenKind::LBrace, "`{`")?;
        let mut beam = None;
        while !self.check(&TokenKind::RBrace) {
            if !self.check(&TokenKind::KwBeam) {
                let field = self.eat_ident()?;
                return Err(ParseError {
                    message: format!("unknown hook field `{}`", field.value),
                    span: field.span,
                    help: Some("a hook has exactly one field, `beam`".to_string()),
                });
            }
            let beam_span = self.advance().span;
            if beam.is_some() {
                return Err(ParseError {
                    message: "duplicate field `beam`".to_string(),
                    span: beam_span,
                    help: None,
                });
            }
            beam = Some(self.parse_beam_ref()?);
        }
        let rbrace = self.expect(TokenKind::RBrace, "`}`")?;
        let Some(beam) = beam else {
            return Err(ParseError {
                message: format!("hook `{}` declares no `beam`", name.value),
                span: name.span,
                help: Some("write `beam <name>` inside the block".to_string()),
            });
        };
        Ok(HookDecl {
            name,
            beam,
            span: Span::new(hook_kw.span.start, rbrace.span.end),
        })
    }

    /// `import "path" as alias`.
    fn parse_import(&mut self) -> Result<Import, ParseError> {
        self.expect(TokenKind::KwImport, "`import`")?;
        let path = self.eat_str()?;
        self.expect(TokenKind::KwAs, "`as`")?;
        let alias = self.eat_ident()?;
        Ok(Import { path, alias })
    }

    /// `let name = expr`.
    fn parse_let(&mut self) -> Result<LetBinding, ParseError> {
        self.expect(TokenKind::KwLet, "`let`")?;
        let name = self.eat_ident()?;
        reject_reserved(&name)?;
        self.expect(TokenKind::Eq, "`=`")?;
        let value = self.parse_expr()?;
        Ok(LetBinding { name, value })
    }

    /// Parses a whole file: `version`, `import`s, `let`s, `default`, and
    /// `beam` declarations, in any order, followed by end of input.
    /// Detects duplicate beam names once every beam has been parsed.
    fn parse_file(&mut self) -> Result<File, ParseError> {
        let mut version = None;
        let mut imports = Vec::new();
        let mut lets = Vec::new();
        let mut default = None;
        let mut beams = Vec::new();
        let mut hooks = Vec::new();

        while !self.is_eof() {
            match self.peek().kind {
                TokenKind::KwVersion => {
                    self.advance();
                    version = Some(self.eat_str()?);
                }
                TokenKind::KwImport => imports.push(self.parse_import()?),
                TokenKind::KwLet => lets.push(self.parse_let()?),
                TokenKind::KwDefault => {
                    self.advance();
                    default = Some(self.eat_ident()?);
                }
                TokenKind::KwBeam => beams.push(self.parse_beam()?),
                TokenKind::KwHook => hooks.push(self.parse_hook()?),
                _ => {
                    return Err(
                        self.unexpected("`version`, `import`, `let`, `default`, `beam`, or `hook`")
                    );
                }
            }
        }

        let mut seen_beam_names = HashSet::new();
        for beam in &beams {
            if !seen_beam_names.insert(beam.name.value.clone()) {
                return Err(ParseError {
                    message: format!("duplicate beam `{}`", beam.name.value),
                    span: beam.name.span,
                    help: None,
                });
            }
        }

        Ok(File {
            version,
            imports,
            lets,
            default,
            beams,
            hooks,
        })
    }
}

/// `git` names the built-in git object; binding it would shadow every
/// `git.*` field at once.
fn reject_reserved(name: &Spanned<String>) -> Result<(), ParseError> {
    if name.value == "git" {
        return Err(ParseError {
            message: "`git` is reserved for the built-in git object".to_string(),
            span: name.span,
            help: Some(
                "pick another name; `git.branch`, `git.sha`, `git.short_sha`, and `git.dirty` \
                 read from it"
                    .to_string(),
            ),
        });
    }
    Ok(())
}

/// The [`describe`] text for [`TokenKind::Eof`], exposed so `template.rs`
/// can recognize (and correct) it when its own synthetic end-of-body
/// sentinel — which reuses `TokenKind::Eof` as the sub-parser's
/// end-of-input marker, but really means "the interpolation's closing
/// `}`" — surfaces in an error message. Kept as a single source of truth
/// rather than a string duplicated in both places, so the two can't drift.
pub(crate) const EOF_DESCRIPTION: &str = "end of input";

/// A short, human-readable description of a token kind for error messages.
fn describe(kind: &TokenKind) -> String {
    match kind {
        TokenKind::Ident(name) => format!("identifier `{name}`"),
        TokenKind::Str(_) => "a string literal".to_string(),
        TokenKind::KwVersion => "`version`".to_string(),
        TokenKind::KwImport => "`import`".to_string(),
        TokenKind::KwAs => "`as`".to_string(),
        TokenKind::KwLet => "`let`".to_string(),
        TokenKind::KwDefault => "`default`".to_string(),
        TokenKind::KwBeam => "`beam`".to_string(),
        TokenKind::KwIf => "`if`".to_string(),
        TokenKind::KwThen => "`then`".to_string(),
        TokenKind::KwElse => "`else`".to_string(),
        TokenKind::KwTrue => "`true`".to_string(),
        TokenKind::KwFalse => "`false`".to_string(),
        TokenKind::KwHook => "`hook`".to_string(),
        TokenKind::LBrace => "`{`".to_string(),
        TokenKind::RBrace => "`}`".to_string(),
        TokenKind::LBracket => "`[`".to_string(),
        TokenKind::RBracket => "`]`".to_string(),
        TokenKind::LParen => "`(`".to_string(),
        TokenKind::RParen => "`)`".to_string(),
        TokenKind::Comma => "`,`".to_string(),
        TokenKind::Colon => "`:`".to_string(),
        TokenKind::Eq => "`=`".to_string(),
        TokenKind::EqEq => "`==`".to_string(),
        TokenKind::NotEq => "`!=`".to_string(),
        TokenKind::AndAnd => "`&&`".to_string(),
        TokenKind::OrOr => "`||`".to_string(),
        TokenKind::Plus => "`+`".to_string(),
        TokenKind::Dot => "`.`".to_string(),
        TokenKind::Newline => "a newline".to_string(),
        TokenKind::Eof => EOF_DESCRIPTION.to_string(),
    }
}

/// Suggests the closest known beam field name to `name`, if any is within
/// Levenshtein distance 2 (picking the closest on ties by array order).
fn suggest_field(name: &str) -> Option<&'static str> {
    BEAM_FIELDS
        .iter()
        .map(|&field| (field, levenshtein(name, field)))
        .filter(|&(_, distance)| distance <= 2)
        .min_by_key(|&(_, distance)| distance)
        .map(|(field, _)| field)
}

/// Classic dynamic-programming Levenshtein edit distance between two
/// strings, operating on `char`s (not bytes) so it stays correct for
/// non-ASCII field-name typos.
fn levenshtein(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();

    let mut row: Vec<usize> = (0..=b.len()).collect();
    for i in 1..=a.len() {
        let mut prev_diag = row[0];
        row[0] = i;
        for j in 1..=b.len() {
            let temp = row[j];
            row[j] = if a[i - 1] == b[j - 1] {
                prev_diag
            } else {
                1 + prev_diag.min(row[j]).min(row[j - 1])
            };
            prev_diag = temp;
        }
    }
    row[b.len()]
}

#[cfg(test)]
mod tests {
    use crate::parse;

    #[test]
    fn parses_full_beamfile() {
        let src = r#"
version "1"
import "api/Beamfile" as api
let profile = env("PROFILE", "debug")
default build

beam build {
  description "Compile"
  needs [api:build, codegen]
  inputs ["src/**/*.rs"]
  run "cargo build"
}

beam deploy(target) {
  needs [build]
  allow_failure true
  run ["echo one", "echo two"]
}
"#;
        let file = parse(src).unwrap();
        assert_eq!(file.beams.len(), 2);
        assert_eq!(file.default.as_ref().unwrap().value, "build");
        let build = &file.beams[0];
        assert_eq!(build.needs[0].value.namespace, ["api"]);
        let deploy = &file.beams[1];
        assert_eq!(deploy.params[0].value, "target");
        assert!(deploy.allow_failure);
        assert_eq!(deploy.run.len(), 2);
    }

    #[test]
    fn rejects_unknown_beam_field_with_help() {
        let err = parse("beam x { descriptoin \"typo\" }").unwrap_err();
        assert!(err.message.contains("unknown field"));
        assert_eq!(err.help.as_deref(), Some("did you mean `description`?"));
    }

    #[test]
    fn rejects_duplicate_beam_names() {
        let err = parse("beam x { run \"a\" }\nbeam x { run \"b\" }").unwrap_err();
        assert!(err.message.contains("duplicate beam"));
    }

    #[test]
    fn rejects_duplicate_field_in_same_beam() {
        let err = parse("beam x { run \"a\" run \"b\" }").unwrap_err();
        assert!(err.message.contains("duplicate field"));
    }

    #[test]
    fn needs_list_accepts_trailing_comma_and_newlines() {
        let file = parse("beam x {\n  needs [\n    a,\n    b,\n  ]\n}").unwrap();
        let needs = &file.beams[0].needs;
        assert_eq!(needs.len(), 2);
        assert_eq!(needs[0].value.name, "a");
        assert_eq!(needs[1].value.name, "b");
    }

    /// A beam reached through a chain of import aliases has a
    /// multi-segment id (`api:db:migrate`), and `needs` must be able to
    /// spell it — otherwise an id Alba creates, lists, and accepts as a
    /// run target cannot be depended on.
    #[test]
    fn needs_accepts_a_multi_segment_namespace() {
        let file = parse("beam x { needs [api:db:migrate] }").unwrap();

        let entry = &file.beams[0].needs[0];
        assert_eq!(entry.value.namespace, ["api", "db"]);
        assert_eq!(entry.value.name, "migrate");
        assert_eq!(
            &"beam x { needs [api:db:migrate] }"[entry.span.start..entry.span.end],
            "api:db:migrate"
        );
    }

    #[test]
    fn parses_let_with_equality_expression() {
        let file = parse(r#"let release = profile == "release""#).unwrap();
        assert_eq!(file.lets.len(), 1);
        assert_eq!(file.lets[0].name.value, "release");
    }

    #[test]
    fn parses_env_and_executor_blocks() {
        let file = parse(
            r#"beam deploy(target) {
  executor docker { image "deployer:latest" }
  env { DEPLOY_TARGET = target }
  run "./scripts/deploy.sh {target}"
}"#,
        )
        .unwrap();
        let beam = &file.beams[0];
        let executor = beam.executor.as_ref().unwrap();
        assert_eq!(executor.name.value, "docker");
        assert_eq!(executor.options[0].0.value, "image");
        assert!(matches!(
            &executor.options[0].1,
            crate::ast::ExecutorOptionValue::Str(t)
                if t.parts == vec![crate::TemplatePart::Literal("deployer:latest".to_string())]
        ));
        assert_eq!(beam.env[0].0.value, "DEPLOY_TARGET");
        // `target` is a bare identifier (no quotes), so it becomes a
        // single-part template wrapping a variable reference, equivalent
        // to writing `"{target}"`.
        assert!(matches!(
            &beam.env[0].1.parts[..],
            [crate::TemplatePart::Expr(crate::Expr::Var(v))] if v.value == "target"
        ));
    }

    /// `executor system_shell` and `executor shell` name an executor that
    /// takes no options, so the `{ ... }` block is optional. Which names
    /// are actually valid executors is `alba-core`'s concern, not the
    /// parser's — the parser only decides whether a block follows.
    #[test]
    fn executor_without_a_block_parses() {
        let file = parse("beam b {\n  executor system_shell\n  run \"x\"\n}").unwrap();
        let executor = file.beams[0].executor.as_ref().unwrap();
        assert_eq!(executor.name.value, "system_shell");
        assert!(executor.options.is_empty());
    }

    #[test]
    fn executor_with_a_block_still_parses() {
        let file = parse("beam b {\n  executor docker { image \"i\" }\n  run \"x\"\n}").unwrap();
        let executor = file.beams[0].executor.as_ref().unwrap();
        assert_eq!(executor.name.value, "docker");
        assert_eq!(executor.options[0].0.value, "image");
    }

    #[test]
    fn reports_unexpected_token_span_not_enclosing_construct() {
        let err = parse("beam x { needs [a, }").unwrap_err();
        // The span must point at the offending `}`, not at `needs` or `beam`.
        assert_eq!(err.span, crate::Span::new(19, 20));
    }
}