Skip to main content

opy_rs/
parser.rs

1//! The indentation-aware `.opy` CST parser.
2//!
3//! Consumes the expanded token stream from [`crate::preprocess`] and builds a
4//! [`cst::Program`]. Parsing is deterministic and corpus-backed; malformed
5//! input produces structured [`OpyError`]s rather than panics, and the
6//! parser recovers at statement/line boundaries so multiple useful errors are
7//! reported. The returned [`ParseOutput`] carries either a complete program
8//! or the collected errors (never both).
9
10use std::collections::HashMap;
11
12use crate::cst::{
13    Annotation, AnnotationArg, CallArg, Decl, DictEntry, Event, Expr, IfBranch, Program, Rule,
14    RuleEntry, Stmt, SwitchArm, TopLevel,
15};
16use crate::diag::{OpyError, Position, Span};
17use crate::lexer::{Token, TokenKind, decode_string_escape, is_identifier};
18
19/// The outcome of a parse.
20#[derive(Debug, Default)]
21pub struct ParseOutput {
22    /// The parsed program, present only when no errors were collected.
23    pub program: Option<Program>,
24    /// Every structured error collected during the parse.
25    pub errors: Vec<OpyError>,
26}
27
28/// Parse an expanded token stream into a CST program.
29pub fn parse(tokens: &[Token]) -> ParseOutput {
30    parse_with_options(tokens, false)
31}
32
33/// Parse with the global redeclaration policy observed by the pinned oracle.
34pub fn parse_with_options(tokens: &[Token], allow_macro_redeclaration: bool) -> ParseOutput {
35    let mut parser = Parser::new(tokens, allow_macro_redeclaration);
36    let program = parser.parse_program();
37    if parser.errors.is_empty() {
38        ParseOutput {
39            program: Some(program),
40            errors: Vec::new(),
41        }
42    } else {
43        ParseOutput {
44            program: None,
45            errors: parser.errors,
46        }
47    }
48}
49
50mod declarations;
51mod definitions;
52mod expressions;
53mod statements;
54
55struct Parser<'a> {
56    tokens: &'a [Token],
57    /// Unmatched `(`/`[`/`{` count within `tokens[..i]`, so
58    /// `inside_delimiter_group` answers in O(1) instead of rescanning the
59    /// consumed prefix for every expression level.
60    delimiter_depth: Vec<u32>,
61    /// Per-file `(token index, running max start line)` for every `Newline`
62    /// token, so conditional-expression parsing can ask whether a consumed
63    /// newline starts after a given line without rescanning the prefix.
64    newline_lines: HashMap<u32, Vec<(usize, u32)>>,
65    pos: usize,
66    errors: Vec<OpyError>,
67    allow_macro_redeclaration: bool,
68    last_statement_continued: bool,
69    last_colon_body_continued: bool,
70    /// Columns of the `if` statements whose branches are being parsed.
71    open_if_indents: Vec<u32>,
72    /// Set once a `workshop-source` diagnostic has been reported; later
73    /// Workshop-looking constructs are skipped silently so a pasted Workshop
74    /// script produces one diagnostic instead of a cascade.
75    workshop_source_reported: bool,
76}
77
78impl<'a> Parser<'a> {
79    fn new(tokens: &'a [Token], allow_macro_redeclaration: bool) -> Self {
80        let mut delimiter_depth = Vec::with_capacity(tokens.len() + 1);
81        delimiter_depth.push(0);
82        let mut depth = 0u32;
83        let mut newline_lines: HashMap<u32, Vec<(usize, u32)>> = HashMap::new();
84        for (index, token) in tokens.iter().enumerate() {
85            match token.kind {
86                TokenKind::LParen | TokenKind::LBracket | TokenKind::LBrace => depth += 1,
87                TokenKind::RParen | TokenKind::RBracket | TokenKind::RBrace => {
88                    depth = depth.saturating_sub(1)
89                }
90                _ => {}
91            }
92            delimiter_depth.push(depth);
93            if token.kind == TokenKind::Newline {
94                let entries = newline_lines.entry(token.span.file).or_default();
95                let line = token
96                    .span
97                    .start
98                    .line
99                    .max(entries.last().map_or(0, |&(_, max)| max));
100                entries.push((index, line));
101            }
102        }
103        Self {
104            tokens,
105            delimiter_depth,
106            newline_lines,
107            pos: 0,
108            errors: Vec::new(),
109            allow_macro_redeclaration,
110            last_statement_continued: false,
111            last_colon_body_continued: false,
112            open_if_indents: Vec::new(),
113            workshop_source_reported: false,
114        }
115    }
116}
117
118/// OPY keywords and expression words that can validly sit in front of an
119/// identifier or a `(`/`-delimited operand; they must never count toward a
120/// multi-word Workshop call name such as `Set Player Variable`.
121fn is_opy_word(text: &str) -> bool {
122    matches!(
123        text,
124        "and"
125            | "or"
126            | "not"
127            | "in"
128            | "lambda"
129            | "if"
130            | "elif"
131            | "else"
132            | "for"
133            | "while"
134            | "do"
135            | "switch"
136            | "del"
137            | "goto"
138            | "continue"
139            | "break"
140            | "return"
141            | "pass"
142            | "case"
143            | "default"
144    )
145}
146
147/// The `workshop-source` detail for `{`/`}` brace scaffolding.
148const BRACE_BLOCKS: &str = "a `{`/`}` brace block; OPY blocks open with `:` and indentation";
149
150fn is_binary_operator(kind: TokenKind) -> bool {
151    matches!(
152        kind,
153        TokenKind::Plus
154            | TokenKind::Minus
155            | TokenKind::Star
156            | TokenKind::Slash
157            | TokenKind::Percent
158            | TokenKind::DoubleStar
159            | TokenKind::Eq
160            | TokenKind::Ne
161            | TokenKind::Lt
162            | TokenKind::Le
163            | TokenKind::Gt
164            | TokenKind::Ge
165    )
166}
167
168fn unquote_annotation_arg(text: &str) -> String {
169    text.strip_prefix('"')
170        .and_then(|value| value.strip_suffix('"'))
171        .unwrap_or(text)
172        .to_string()
173}
174
175impl Parser<'_> {
176    fn peek(&self) -> &Token {
177        &self.tokens[self.pos.min(self.tokens.len() - 1)]
178    }
179
180    fn peek_kind(&self) -> TokenKind {
181        self.peek().kind
182    }
183
184    fn peek_at(&self, offset: usize) -> &Token {
185        &self.tokens[(self.pos + offset).min(self.tokens.len() - 1)]
186    }
187
188    fn advance(&mut self) -> Token {
189        let token = self.tokens[self.pos.min(self.tokens.len() - 1)].clone();
190        if self.pos < self.tokens.len() - 1 {
191            self.pos += 1;
192        }
193        token
194    }
195
196    /// Consume the current token without cloning it, for sites that move the
197    /// cursor rather than inspect the token.
198    fn bump(&mut self) {
199        if self.pos < self.tokens.len() - 1 {
200            self.pos += 1;
201        }
202    }
203
204    /// Whether the consumed prefix holds a `Newline` token in `file` whose
205    /// start line exceeds `line` — the same answer as scanning
206    /// `tokens[..self.pos]`, in O(log) via the per-file prefix maxima.
207    fn newline_after_line(&self, file: u32, line: u32) -> bool {
208        let Some(entries) = self.newline_lines.get(&file) else {
209            return false;
210        };
211        let consumed = entries.partition_point(|&(index, _)| index < self.pos);
212        consumed > 0 && entries[consumed - 1].1 > line
213    }
214
215    fn skip_newlines(&mut self) {
216        while self.peek_kind() == TokenKind::Newline {
217            self.bump();
218        }
219    }
220
221    fn skip_expression_newlines(&mut self) {
222        if self.peek_kind() != TokenKind::Newline {
223            return;
224        }
225        let previous = self.tokens[..self.pos]
226            .iter()
227            .rev()
228            .find(|token| !matches!(token.kind, TokenKind::Indent(_)));
229        let previous_allows_continuation = previous.is_some_and(|token| {
230            matches!(
231                token.kind,
232                TokenKind::LParen
233                    | TokenKind::LBracket
234                    | TokenKind::LBrace
235                    | TokenKind::Comma
236                    | TokenKind::Colon
237                    | TokenKind::Assign
238            ) || is_binary_operator(token.kind)
239                || (token.kind == TokenKind::Ident
240                    && matches!(token.text.as_str(), "and" | "or" | "in" | "not" | "if"))
241        });
242        let mut next = self.pos;
243        while self.tokens[next].kind == TokenKind::Newline {
244            next += 1;
245        }
246        let inside_delimiter_group = self.inside_delimiter_group();
247        let next_allows_continuation = is_binary_operator(self.tokens[next].kind)
248            || (inside_delimiter_group
249                && matches!(
250                    self.tokens[next].kind,
251                    TokenKind::LParen
252                        | TokenKind::LBracket
253                        | TokenKind::Dot
254                        | TokenKind::RParen
255                        | TokenKind::RBracket
256                        | TokenKind::RBrace
257                ))
258            || (self.tokens[next].kind == TokenKind::Ident
259                && matches!(
260                    self.tokens[next].text.as_str(),
261                    "and" | "or" | "in" | "not" | "else"
262                ))
263            || (inside_delimiter_group
264                && self.tokens[next].kind == TokenKind::Ident
265                && matches!(self.tokens[next].text.as_str(), "if" | "for"));
266        if previous_allows_continuation || next_allows_continuation {
267            self.skip_newlines();
268        }
269    }
270
271    fn is_ident(&self, text: &str) -> bool {
272        self.peek_kind() == TokenKind::Ident && self.peek().text == text
273    }
274
275    fn expect_ident(&mut self, what: &str) -> Result<String, ()> {
276        if self.peek_kind() == TokenKind::Ident {
277            Ok(self.advance().text)
278        } else {
279            self.error_at_current(format!("expected {what}"));
280            Err(())
281        }
282    }
283
284    fn expect(&mut self, kind: TokenKind, what: &str) -> Result<Token, ()> {
285        if self.peek_kind() == kind {
286            Ok(self.advance())
287        } else {
288            self.error_at_current(format!("expected {what}"));
289            Err(())
290        }
291    }
292
293    fn error_at_current(&mut self, message: String) {
294        let span = self.peek().span;
295        self.errors.push(OpyError::at("parse-error", message, span));
296    }
297
298    // ---- Workshop-script detection ----
299    //
300    // Raw Workshop script shares no surface syntax with OPY: it opens blocks
301    // with `{`/`}` braces, terminates statements with `;`, and calls actions
302    // through space-separated multi-word names such as `Set Player
303    // Variable(...)`. When a line carries one of those tells the source is
304    // not OPY at all, so it is reported once — at the first construct — and
305    // the construct is skipped instead of cascading a generic parse error
306    // per following token (issue #420).
307
308    /// The Workshop-script tell in the construct starting at the current
309    /// position: `(span, detail)` when the line cannot be OPY.
310    pub(super) fn workshop_construct(&self, top_level: bool) -> Option<(Span, String)> {
311        let mut end = self.pos;
312        while !matches!(self.tokens[end].kind, TokenKind::Newline | TokenKind::Eof) {
313            end += 1;
314        }
315        let line = &self.tokens[self.pos..end];
316        let first = line.first()?;
317        let last = line.last().expect("line is non-empty");
318        // `;` is never a valid OPY statement terminator, at any level.
319        if last.kind == TokenKind::Semicolon {
320            return Some((
321                Span::new(first.span.file, first.span.start, last.span.end),
322                "a `;` statement terminator; OPY ends statements at the line".to_string(),
323            ));
324        }
325        // Inside a rule body, adjacent bare expressions are legal OPY
326        // statements (`x`, then `f()`, then `{k: v}`), so `ident {`- and
327        // `ident…(`-shaped lines cannot be told apart from OPY there — a
328        // `{`/`;`/`:` tell or a keyword position has to flag them instead.
329        // At top level no declaration begins with a bare identifier, so
330        // these shapes are unambiguous.
331        if !top_level {
332            return None;
333        }
334        match first.kind {
335            // `}` can never start an OPY construct.
336            TokenKind::RBrace => {
337                return Some((
338                    first.span,
339                    "a `}` only closes a Workshop brace block; OPY blocks end by dedenting"
340                        .to_string(),
341                ));
342            }
343            // A lone `{` opens a dict literal inside a rule, but nothing at
344            // top level begins with one.
345            TokenKind::LBrace => {
346                return Some((first.span, BRACE_BLOCKS.to_string()));
347            }
348            _ => {}
349        }
350        if first.kind != TokenKind::Ident {
351            return None;
352        }
353        // `ident {` opens a brace block: `event {`, `actions {`, `variables {`.
354        // OPY words are excluded for uniformity with the multi-word scan.
355        if line
356            .get(1)
357            .is_some_and(|token| token.kind == TokenKind::LBrace)
358            && !is_opy_word(&first.text)
359        {
360            return Some((
361                Span::new(first.span.file, first.span.start, line[1].span.end),
362                BRACE_BLOCKS.to_string(),
363            ));
364        }
365        // `Word Word ... (` is a multi-word Workshop call name; a bare
366        // juxtaposition without a call (`x y`) stays plain OPY.
367        let mut words = 0;
368        while line
369            .get(words)
370            .is_some_and(|token| token.kind == TokenKind::Ident && !is_opy_word(&token.text))
371        {
372            words += 1;
373        }
374        if words >= 2
375            && line
376                .get(words)
377                .is_some_and(|token| token.kind == TokenKind::LParen)
378        {
379            let name = line[..words]
380                .iter()
381                .map(|token| token.text.as_str())
382                .collect::<Vec<_>>()
383                .join(" ");
384            return Some((
385                Span::new(first.span.file, first.span.start, line[words].span.end),
386                format!("a multi-word call name `{name}`; OPY calls use a single name"),
387            ));
388        }
389        None
390    }
391
392    /// Report the file's one `workshop-source` diagnostic; later constructs
393    /// are skipped silently.
394    pub(super) fn report_workshop_source(&mut self, span: Span, detail: &str) {
395        if self.workshop_source_reported {
396            return;
397        }
398        self.workshop_source_reported = true;
399        self.errors.push(OpyError::at(
400            "workshop-source",
401            format!("this looks like Workshop script, not OPY ({detail})"),
402            span,
403        ));
404    }
405
406    /// Consume the rest of the construct's line; when it opened a `{` block,
407    /// keep consuming until the matching `}` (or EOF).
408    pub(super) fn skip_workshop_construct(&mut self) {
409        let mut depth = 0u32;
410        loop {
411            match self.peek_kind() {
412                TokenKind::Eof => break,
413                TokenKind::Newline if depth == 0 => break,
414                TokenKind::LBrace => depth += 1,
415                TokenKind::RBrace => depth = depth.saturating_sub(1),
416                _ => {}
417            }
418            self.bump();
419        }
420    }
421
422    /// `:` where a block opens; a `{` in that position is a Workshop brace
423    /// block rather than a mistyped colon.
424    pub(super) fn expect_block_colon(&mut self, colon_context: &str) -> Result<(), ()> {
425        if self.peek_kind() == TokenKind::LBrace {
426            let span = self.peek().span;
427            self.report_workshop_source(span, BRACE_BLOCKS);
428            self.skip_workshop_construct();
429            return Err(());
430        }
431        self.expect(TokenKind::Colon, colon_context).map(|_| ())
432    }
433
434    // ---- program ----
435
436    fn parse_program(&mut self) -> Program {
437        let mut declarations = Vec::new();
438        let mut rules = Vec::new();
439        let mut top_level = Vec::new();
440        loop {
441            self.skip_newlines();
442            if self.peek_kind() == TokenKind::Eof {
443                break;
444            }
445            let rule_prefix = if self.peek_kind() == TokenKind::RulePrefixMarker {
446                Some(self.advance().text)
447            } else {
448                None
449            };
450            let declaration_count = declarations.len();
451            let rule_count = rules.len();
452            let ok = self.parse_top_level(&mut declarations, &mut rules, rule_prefix);
453            if ok {
454                if declarations.len() > declaration_count {
455                    top_level.push(TopLevel::Declaration(
456                        declarations
457                            .last()
458                            .expect("declaration was appended")
459                            .clone(),
460                    ));
461                } else if rules.len() > rule_count {
462                    top_level.push(TopLevel::Rule(
463                        rules.last().expect("rule was appended").clone(),
464                    ));
465                }
466            }
467            if !ok {
468                self.recover_line();
469            }
470        }
471        Program {
472            declarations,
473            rules,
474            top_level,
475            settings: None,
476        }
477    }
478
479    fn parse_top_level(
480        &mut self,
481        declarations: &mut Vec<Decl>,
482        rules: &mut Vec<RuleEntry>,
483        rule_prefix: Option<String>,
484    ) -> bool {
485        let token = self.peek();
486        if token.kind == TokenKind::Ident {
487            match token.text.as_str() {
488                "rule" => return self.parse_rule(rules, rule_prefix),
489                "def" => return self.parse_def(rules, rule_prefix),
490                "globalvar" => return self.parse_variable(declarations, true),
491                "playervar" => return self.parse_variable(declarations, false),
492                "subroutine" => return self.parse_subroutine(declarations),
493                "enum" => return self.parse_enum(declarations),
494                "macro" => return self.parse_macro(declarations),
495                _ => {}
496            }
497        }
498        if let Some((span, detail)) = self.workshop_construct(true) {
499            self.report_workshop_source(span, &detail);
500            self.skip_workshop_construct();
501        } else {
502            self.error_at_current(format!(
503                "expected a top-level declaration (rule/def/globalvar/playervar/subroutine/enum/macro) but found '{}'",
504                token.text
505            ));
506        }
507        false
508    }
509
510    /// Skip to the end of the current line (error recovery).
511    fn recover_line(&mut self) {
512        while self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof {
513            self.bump();
514        }
515    }
516
517    /// The indentation of the next non-empty line, which must exceed
518    /// `line_indent` (an indented block follows the colon).
519    fn block_indent(&mut self, line_indent: u32) -> Option<u32> {
520        self.skip_newlines();
521        if self.peek_kind() == TokenKind::Eof {
522            self.error_at_current("expected an indented block".to_string());
523            return None;
524        }
525        let indent = self.peek().span.start.col;
526        if indent <= line_indent {
527            self.error_at_current("expected an indented block after ':'".to_string());
528            return None;
529        }
530        Some(indent)
531    }
532
533    pub(super) fn expect_block_indent(
534        &mut self,
535        line_indent: u32,
536        colon_context: &str,
537    ) -> Result<u32, ()> {
538        self.expect_block_colon(colon_context)?;
539        self.block_indent(line_indent).ok_or(())
540    }
541
542    fn expect_statement_end(&mut self, what: &str) -> Result<(), ()> {
543        let continued_line = self
544            .tokens
545            .get(self.pos.saturating_sub(1))
546            .is_some_and(|previous| self.peek().span.start.line > previous.span.end.line);
547        self.last_statement_continued = continued_line;
548        if matches!(self.peek_kind(), TokenKind::Newline | TokenKind::Eof) || continued_line {
549            Ok(())
550        } else {
551            self.error_at_current(format!("expected the end of {what}"));
552            Err(())
553        }
554    }
555}
556
557/// Parse one f-string expression fragment and shift its local token spans
558/// into the original source file.
559pub(crate) fn parse_expression_fragment(
560    text: &str,
561    file: u32,
562    origin: Position,
563) -> Result<Expr, OpyError> {
564    let mut tokens = crate::lexer::lex(crate::lexer::LexInput {
565        file_id: file,
566        text,
567    })?;
568    for token in &mut tokens {
569        token.span = shift_span(token.span, origin);
570    }
571    let mut parser = Parser::new(&tokens, false);
572    let expression = parser.parse_expr().map_err(|()| {
573        parser.errors.first().cloned().unwrap_or_else(|| {
574            OpyError::at(
575                "parse-error",
576                "invalid f-string expression",
577                Span::new(file, origin, origin),
578            )
579        })
580    })?;
581    if parser.peek_kind() != TokenKind::Eof {
582        parser.error_at_current("unexpected tokens in f-string interpolation".to_string());
583    }
584    parser.errors.into_iter().next().map_or(Ok(expression), Err)
585}
586
587fn shift_span(span: Span, origin: Position) -> Span {
588    Span::new(
589        span.file,
590        crate::diag::shift_position(span.start, origin),
591        crate::diag::shift_position(span.end, origin),
592    )
593}
594
595fn is_string_modifier(text: &str) -> bool {
596    matches!(text, "f" | "w" | "l" | "b" | "c" | "t")
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use crate::lexer::{LexInput, lex};
603
604    fn parse_ok(text: &str) -> Program {
605        let tokens = lex(LexInput { file_id: 0, text }).unwrap();
606        let output = parse(&tokens);
607        assert!(
608            output.errors.is_empty(),
609            "unexpected errors: {:?}",
610            output.errors
611        );
612        output.program.unwrap()
613    }
614
615    fn parse_err(text: &str) -> Vec<OpyError> {
616        let tokens = lex(LexInput { file_id: 0, text }).unwrap();
617        parse(&tokens).errors
618    }
619
620    #[test]
621    fn parses_basic_rule() {
622        let program = parse_ok("rule \"setup\":\n    @Event global\n    disableInspector()\n");
623        assert_eq!(program.rules.len(), 1);
624        let RuleEntry::Rule(rule) = &program.rules[0] else {
625            panic!("expected rule");
626        };
627        assert_eq!(rule.name, "setup");
628        assert_eq!(rule.event.name, "global");
629        assert_eq!(rule.actions.len(), 1);
630    }
631
632    #[test]
633    fn parses_power_augmented_assignment() {
634        // The pinned OverPy 9.7.10 reference accepts `**=` as the power
635        // augmented assignment (`a **= b` ⇔ `a = a ** b`).
636        let program =
637            parse_ok("globalvar a\nrule \"r\":\n    @Event global\n    a = 2\n    a **= 3\n");
638        let RuleEntry::Rule(rule) = &program.rules[0] else {
639            panic!("expected rule");
640        };
641        let Stmt::Assign {
642            value,
643            target: assigned_target,
644            ..
645        } = &rule.actions[1]
646        else {
647            panic!("expected an assignment");
648        };
649        let Expr::Binary {
650            op, left, right, ..
651        } = value
652        else {
653            panic!("expected a binary modification, got {value:?}");
654        };
655        assert_eq!(op, "**");
656        assert!(matches!(&**left, Expr::Name { .. }));
657        assert!(matches!(
658            assigned_target,
659            Expr::Name { name, .. } if name == "a"
660        ));
661        assert!(matches!(right.as_ref(), Expr::Number { .. }));
662    }
663
664    #[test]
665    fn parses_postfix_increment_and_decrement_as_modifications() {
666        let program =
667            parse_ok("globalvar value\nrule \"r\":\n    @Event global\n    value++\n    value--\n");
668        let RuleEntry::Rule(rule) = &program.rules[0] else {
669            panic!("expected a rule");
670        };
671        for (statement, expected_op) in [(&rule.actions[0], "+"), (&rule.actions[1], "-")] {
672            let Stmt::Assign { target, value, .. } = statement else {
673                panic!("expected a postfix assignment");
674            };
675            let Expr::Binary {
676                op, left, right, ..
677            } = value
678            else {
679                panic!("expected a synthetic modification value");
680            };
681            assert_eq!(op, expected_op);
682            let Expr::Name {
683                name: left_name, ..
684            } = left.as_ref()
685            else {
686                panic!("expected the target to be the modification's left operand");
687            };
688            let Expr::Name {
689                name: target_name, ..
690            } = target
691            else {
692                panic!("expected a name target");
693            };
694            assert_eq!(left_name, target_name);
695            assert!(
696                matches!(right.as_ref(), Expr::Number { value, text, .. } if *value == 1.0 && text == "1")
697            );
698        }
699    }
700
701    #[test]
702    fn rejects_prefix_increment_and_embedded_postfix_forms() {
703        for source in [
704            "globalvar value\nrule \"r\":\n    @Event global\n    ++value\n",
705            "globalvar value\nrule \"r\":\n    @Event global\n    value++++\n",
706        ] {
707            let errors = parse_err(source);
708            assert!(!errors.is_empty());
709            assert!(errors.iter().all(|error| error.code == "parse-error"));
710            assert!(errors.iter().all(|error| error.span.is_some()));
711        }
712    }
713
714    #[test]
715    fn preserves_consecutive_unary_minus_expressions() {
716        let source = concat!(
717            "globalvar value = 0\n",
718            "globalvar B = 1\n",
719            "rule \"r\":\n",
720            "    @Event global\n",
721            "    value = --1\n",
722            "    value = --B\n",
723            "    value = B--1\n",
724        );
725        parse_ok(source);
726    }
727
728    #[test]
729    fn parses_control_flow() {
730        let program = parse_ok(
731            "globalvar index = 0\n\nrule \"r\":\n    @Event global\n    for index in range(3):\n        if index == 0:\n            debug(index)\n        elif index == 1:\n            debug(index)\n        else:\n            debug(index)\n    while index < 3:\n        index += 1\n        wait()\n",
732        );
733        let RuleEntry::Rule(rule) = &program.rules[0] else {
734            panic!();
735        };
736        assert!(matches!(rule.actions[0], Stmt::For { .. }));
737        let Stmt::For { body, .. } = &rule.actions[0] else {
738            panic!();
739        };
740        let Stmt::If {
741            branches, r#else, ..
742        } = &body[0]
743        else {
744            panic!();
745        };
746        assert_eq!(branches.len(), 2);
747        assert!(r#else.is_some());
748        let Stmt::While { body, .. } = &rule.actions[1] else {
749            panic!();
750        };
751        assert_eq!(body.len(), 2);
752    }
753
754    #[test]
755    fn parses_source_statement_surface() {
756        let program = parse_ok(concat!(
757            "globalvar value\n",
758            "rule \"r\":\n",
759            "    @Event global\n",
760            "    del value[1]\n",
761            "    value min= 2\n",
762            "    value max= 3\n",
763            "    while value < 4:\n",
764            "        continue\n",
765            "    goto RULE_START\n",
766            "    goto target\n",
767            "    goto loc + value\n",
768            "    target:\n",
769        ));
770        let RuleEntry::Rule(rule) = &program.rules[0] else {
771            panic!("expected rule");
772        };
773        assert!(matches!(rule.actions[0], Stmt::Delete { .. }));
774        for (statement, expected) in [(&rule.actions[1], "min"), (&rule.actions[2], "max")] {
775            let Stmt::Assign { value, .. } = statement else {
776                panic!("expected augmented assignment");
777            };
778            assert!(matches!(value, Expr::Binary { op, .. } if op == expected));
779        }
780        let Stmt::While { body, .. } = &rule.actions[3] else {
781            panic!("expected while");
782        };
783        assert!(matches!(body.as_slice(), [Stmt::Continue { .. }]));
784        assert!(matches!(
785            &rule.actions[4],
786            Stmt::Goto {
787                label: None,
788                offset: None,
789                rule_start: true,
790                ..
791            }
792        ));
793        assert!(matches!(
794            &rule.actions[5],
795            Stmt::Goto {
796                label: Some(label),
797                offset: None,
798                rule_start: false,
799                ..
800            } if label == "target"
801        ));
802        assert!(matches!(
803            &rule.actions[6],
804            Stmt::Goto {
805                label: None,
806                offset: Some(_),
807                rule_start: false,
808                ..
809            }
810        ));
811        assert!(matches!(&rule.actions[7], Stmt::Label { name, .. } if name == "target"));
812    }
813
814    #[test]
815    fn workshop_script_source_reports_one_diagnostic() {
816        // opy-rs#420: pasted Workshop script is a different language surface.
817        // It must produce one `workshop-source` diagnostic at the first
818        // construct instead of a per-line `parse-error` cascade.
819        let errors = parse_err(concat!(
820            "rule \"workshop style\" {\n",
821            "    event {\n",
822            "        Ongoing - Global;\n",
823            "    }\n",
824            "    actions {\n",
825            "        Wait(1, Ignore Condition);\n",
826            "    }\n",
827            "}\n",
828        ));
829        assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
830        let error = &errors[0];
831        assert_eq!(error.code, "workshop-source");
832        assert_eq!(
833            error.span.expect("a source span"),
834            Span::new(0, Position::new(1, 23), Position::new(1, 24))
835        );
836    }
837
838    #[test]
839    fn workshop_script_forms_report_once() {
840        for source in [
841            // `rule("name")` + `;` statements + `Ident {` headers.
842            "rule(\"x\") {\n    event {\n        Ongoing - Global;\n    }\n}\n",
843            // A `;`-terminated statement inside an OPY rule.
844            "rule \"r\":\n    @Event global\n    Ongoing - Global;\n",
845            // An `ident {` section header at top level.
846            "actions {\n    Wait(1);\n}\n",
847            // `if`-with-braces is a brace-block tell, not a mistyped colon.
848            "rule \"r\":\n    @Event global\n    if x == 1 {\n        pass\n    }\n",
849            // A `;` tell followed by a `}` line: the second tell reaches
850            // dispatch and must be suppressed by the once-only flag, not by
851            // the first construct's brace skip.
852            "rule \"r\":\n    @Event global\n    Wait(1);\n}\n",
853            // Two `;` tells on consecutive lines: the second must dedup.
854            "rule \"r\":\n    @Event global\n    a = 1;\n    b = 2;\n",
855        ] {
856            let errors = parse_err(source);
857            assert_eq!(
858                errors.len(),
859                1,
860                "expected one diagnostic for {source:?}, got {errors:?}"
861            );
862            assert_eq!(errors[0].code, "workshop-source");
863        }
864    }
865
866    #[test]
867    fn workshop_tells_only_apply_at_top_level() {
868        // #420: the identifier-shaped tells only fire at top level, where no
869        // OPY declaration starts with a bare identifier. Inside a rule body
870        // the same token shapes are adjacent statements and stay plain OPY.
871        for source in [
872            // `x y(1)` is token-identical to `Set Player Variable(...)`.
873            "x y(1)\n", // `x {` is token-identical to `event {`.
874            "x {}\n",   // A stray `}` at top level closes nothing OPY-side.
875            "}\n",
876        ] {
877            let errors = parse_err(source);
878            assert_eq!(
879                errors.len(),
880                1,
881                "expected one diagnostic for {source:?}, got {errors:?}"
882            );
883            assert_eq!(errors[0].code, "workshop-source");
884        }
885        // In a rule body all of these parse: `x` + `y(1)`/`{}` juxtapose as
886        // statements, and a keyword's `{` is a dict literal.
887        parse_ok(
888            "globalvar x\nglobalvar y\nrule \"r\":\n    @Event global\n    x y(1)\n    x {}\n    if {1: 2}: pass\n",
889        );
890        // A `}` reached via malformed-dict recovery inside a body keeps the
891        // ordinary parse error, not a Workshop mislabel.
892        let errors = parse_err("rule \"r\":\n    @Event global\n    x = {\n        a\n    }\n");
893        assert!(!errors.is_empty(), "malformed dict parsed cleanly");
894        assert!(errors.iter().all(|error| error.code == "parse-error"));
895    }
896
897    #[test]
898    fn workshop_style_action_call_reports_once() {
899        // `Set Player Variable(...)` is the multi-word Workshop action shape;
900        // it only flags at top level, where a bare-identifier line is never a
901        // valid OPY declaration.
902        let errors = parse_err("Set Player Variable(eventPlayer, score, 1)\n");
903        assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
904        let error = &errors[0];
905        assert_eq!(error.code, "workshop-source");
906        assert_eq!(
907            error.span.expect("a source span").start,
908            Position::new(1, 1)
909        );
910    }
911
912    #[test]
913    fn ordinary_syntax_errors_keep_their_diagnostics() {
914        // #420: a plain OPY syntax error keeps its code, message, and span.
915        let errors = parse_err("rule no_quotes:\n");
916        assert_eq!(errors.len(), 1);
917        assert_eq!(errors[0].code, "parse-error");
918        // The message is not contractual; pin its content loosely.
919        assert!(errors[0].message.contains("rule name string"));
920        assert_eq!(
921            errors[0].span.expect("a source span").start,
922            Position::new(1, 6)
923        );
924        // Juxtaposed names without a call still parse as adjacent statements.
925        parse_ok("globalvar x\nglobalvar y\nrule \"r\":\n    @Event global\n    x y\n");
926    }
927
928    #[test]
929    fn rejects_invalid_source_statement_forms() {
930        for source in [
931            "rule \"r\":\n    @Event global\n    del value\n",
932            "rule \"r\":\n    @Event global\n    goto\n",
933            "rule \"r\":\n    @Event global\n    goto loc\n",
934            "rule \"r\":\n    @Event global\n    goto target extra\n",
935            "rule \"r\":\n    @Event global\n    continue now\n",
936            "rule \"r\":\n    @Event global\n    A = 1; A = 2\n",
937        ] {
938            let errors = parse_err(source);
939            assert!(!errors.is_empty(), "invalid form parsed: {source}");
940            assert!(errors.iter().all(|error| error.code == "parse-error"));
941            assert!(errors.iter().all(|error| error.span.is_some()));
942        }
943    }
944
945    #[test]
946    fn rejects_continued_inline_if_without_else() {
947        let errors = parse_err(concat!(
948            "globalvar value\n",
949            "macro nextHero():\n",
950            "    value = 1\\\n",
951            "    if value == 0: value = 2\\\n",
952            "    value = 3\\\n",
953        ));
954        assert!(errors.iter().any(|error| {
955            error.code == "parse-error" && error.message == "Found 'if', but no 'else'"
956        }));
957    }
958
959    #[test]
960    fn parses_syntax_constructs() {
961        let program = parse_ok(
962            "globalvar x\nrule \"r\":\n    @Event global\n    switch x:\n        case 0x10:\n            x = 1 in [1, 2]\n        default:\n            do:\n                x = {\"x\": 1}[\"x\"]\n            while x not in [2, 3]\n    x = [value * 2 for value, index in [1, 2] if value > index]\n    x = sorted([1, 2], key=lambda value: value)\n    x = w\"wide\"\n",
963        );
964        let RuleEntry::Rule(rule) = &program.rules[0] else {
965            panic!("expected rule");
966        };
967        assert!(matches!(rule.actions[0], Stmt::Switch { .. }));
968        assert!(matches!(rule.actions[1], Stmt::Assign { .. }));
969    }
970
971    #[test]
972    fn rejects_incomplete_do_while_and_dictionary_entries() {
973        let errors = parse_err(
974            "rule \"r\":\n    @Event global\n    do:\n        pass\n    while\n    x = {\"x\"}\n",
975        );
976        assert!(!errors.is_empty());
977        assert!(errors.iter().all(|error| error.code == "parse-error"));
978    }
979
980    #[test]
981    fn parses_explicit_enum_member_values() {
982        let program = parse_ok("enum EventType:\n    BUFF = 0\n    DEBUFF\n    MECH = 2\n");
983        let Decl::Enum { members, .. } = &program.declarations[0] else {
984            panic!("expected enum");
985        };
986        assert_eq!(
987            members
988                .iter()
989                .map(|(name, _)| name.as_str())
990                .collect::<Vec<_>>(),
991            ["BUFF", "DEBUFF", "MECH"]
992        );
993    }
994
995    #[test]
996    fn parses_multi_line_array() {
997        let program = parse_ok(
998            "globalvar p\nrule \"r\":\n    @Event global\n    p = [\n        vect(1, 0, 0),\n        vect(2, 0, 0),\n    ]\n",
999        );
1000        let RuleEntry::Rule(rule) = &program.rules[0] else {
1001            panic!();
1002        };
1003        let Stmt::Assign { value, .. } = &rule.actions[0] else {
1004            panic!();
1005        };
1006        let Expr::Array { elements, .. } = value else {
1007            panic!("expected array, got {value:?}");
1008        };
1009        assert_eq!(elements.len(), 2);
1010    }
1011
1012    #[test]
1013    fn missing_colon_is_a_structured_error() {
1014        let errors = parse_err("rule \"x\"\n    @Event global\n");
1015        assert!(!errors.is_empty());
1016        assert_eq!(errors[0].code, "parse-error");
1017        assert!(errors[0].span.is_some());
1018    }
1019
1020    #[test]
1021    fn def_and_macro_parse() {
1022        let program = parse_ok(
1023            "subroutine showStatus\n\nmacro VERSION = \"1.4.3\"\n\ndef showStatus():\n    print(\"hi\")\n\nmacro double(value):\n    value + value\n",
1024        );
1025        assert_eq!(program.declarations.len(), 3);
1026        assert!(matches!(program.declarations[1], Decl::Constant { .. }));
1027        assert!(matches!(program.declarations[2], Decl::Macro { .. }));
1028        let Decl::Macro { args, body, .. } = &program.declarations[2] else {
1029            panic!();
1030        };
1031        assert_eq!(args, &vec!["value".to_string()]);
1032        assert_eq!(body.len(), 1);
1033    }
1034
1035    #[test]
1036    fn macro_and_enum_redeclarations_are_checked_at_ast_surfaces() {
1037        let text = "enum Kind:\n    First\n    First\nmacro helper():\n    pass\nmacro helper():\n    pass\n";
1038        let errors = parse_err(text);
1039        assert_eq!(
1040            errors
1041                .iter()
1042                .filter(|error| error.code == "macro-redeclaration")
1043                .count(),
1044            2
1045        );
1046
1047        let tokens = lex(LexInput { file_id: 0, text }).unwrap();
1048        let output = parse_with_options(&tokens, true);
1049        assert!(
1050            output.errors.is_empty(),
1051            "unexpected errors: {:?}",
1052            output.errors
1053        );
1054        assert!(output.program.is_some());
1055    }
1056
1057    #[test]
1058    fn multiple_errors_are_reported() {
1059        let errors =
1060            parse_err("rule \"a\"\n    bad statement here\nrule \"b\"\n    @Event global\n");
1061        assert!(!errors.is_empty());
1062    }
1063
1064    #[test]
1065    fn precedence_parses_python_like() {
1066        let program = parse_ok("globalvar x\nrule \"r\":\n    @Event global\n    x = 1 + 2 * 3\n");
1067        let RuleEntry::Rule(rule) = &program.rules[0] else {
1068            panic!();
1069        };
1070        let Stmt::Assign { value, .. } = &rule.actions[0] else {
1071            panic!();
1072        };
1073        let Expr::Binary {
1074            op, left, right, ..
1075        } = value
1076        else {
1077            panic!();
1078        };
1079        assert_eq!(op, "+");
1080        let Expr::Binary { op: inner, .. } = right.as_ref() else {
1081            panic!();
1082        };
1083        assert_eq!(inner, "*");
1084        assert!(matches!(left.as_ref(), Expr::Number { .. }));
1085    }
1086
1087    #[test]
1088    fn parses_right_associative_conditional_expressions() {
1089        let program = parse_ok(
1090            "rule \"r\":\n    @Event global\n    debug(1 if true else 2 if false else 3)\n",
1091        );
1092        let RuleEntry::Rule(rule) = &program.rules[0] else {
1093            panic!("expected a rule");
1094        };
1095        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1096            panic!("expected an expression statement");
1097        };
1098        let Expr::Call { args, .. } = expr else {
1099            panic!("expected a call");
1100        };
1101        let Expr::Conditional {
1102            then_value,
1103            condition,
1104            else_value,
1105            span,
1106        } = &args[0].value
1107        else {
1108            panic!("expected a conditional expression");
1109        };
1110        assert!(matches!(then_value.as_ref(), Expr::Number { value, .. } if *value == 1.0));
1111        assert!(matches!(condition.as_ref(), Expr::Bool { value: true, .. }));
1112        assert!(matches!(
1113            else_value.as_ref(),
1114            Expr::Conditional { then_value, condition, else_value, .. }
1115                if matches!(then_value.as_ref(), Expr::Number { value, .. } if *value == 2.0)
1116                    && matches!(condition.as_ref(), Expr::Bool { value: false, .. })
1117                    && matches!(else_value.as_ref(), Expr::Number { value, .. } if *value == 3.0)
1118        ));
1119        assert_eq!(span.start.line, 3);
1120        assert_eq!(span.start.col, 11);
1121    }
1122
1123    #[test]
1124    fn parses_parenthesized_nested_conditional_and_rejects_missing_else() {
1125        let program = parse_ok(
1126            "rule \"r\":\n    @Event global\n    debug((1 if true else 2) if false else 3)\n",
1127        );
1128        let RuleEntry::Rule(rule) = &program.rules[0] else {
1129            panic!("expected a rule");
1130        };
1131        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1132            panic!("expected an expression statement");
1133        };
1134        let Expr::Call { args, .. } = expr else {
1135            panic!("expected a call");
1136        };
1137        assert!(matches!(
1138            &args[0].value,
1139            Expr::Conditional {
1140                then_value,
1141                condition,
1142                else_value,
1143                ..
1144            } if matches!(then_value.as_ref(), Expr::Conditional { .. })
1145                && matches!(condition.as_ref(), Expr::Bool { value: false, .. })
1146                && matches!(else_value.as_ref(), Expr::Number { value, .. } if *value == 3.0)
1147        ));
1148
1149        let errors = parse_err("rule \"r\":\n    @Event global\n    debug(1 if true)\n");
1150        assert_eq!(errors[0].code, "parse-error");
1151        assert!(errors[0].message.contains("expected `else`"));
1152    }
1153
1154    #[test]
1155    fn parses_receiver_calls() {
1156        // `eventPlayer.setMoveSpeed(100)` is a receiver call: postfix `.`
1157        // member access followed by call arguments (#104).
1158        let program =
1159            parse_ok("rule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n");
1160        let RuleEntry::Rule(rule) = &program.rules[0] else {
1161            panic!("expected rule");
1162        };
1163        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1164            panic!("expected expression statement, got {:?}", rule.actions[0]);
1165        };
1166        let Expr::ReceiverCall {
1167            receiver,
1168            name,
1169            args,
1170            ..
1171        } = &expr
1172        else {
1173            panic!("expected receiver call, got {expr:?}");
1174        };
1175        assert_eq!(name, "setMoveSpeed");
1176        assert!(
1177            matches!(receiver.as_ref(), Expr::Name { name, .. } if name == "eventPlayer"),
1178            "receiver must be the eventPlayer name"
1179        );
1180        assert_eq!(args.len(), 1);
1181        assert!(args[0].keyword.is_none(), "positional argument");
1182        assert!(matches!(&args[0].value, Expr::Number { .. }));
1183    }
1184
1185    #[test]
1186    fn parses_keyword_arguments_with_name_spans() {
1187        // `name = expr` call arguments are keyword arguments carrying the
1188        // name token's exact span (issue #110); comparisons stay positional.
1189        let program =
1190            parse_ok("rule \"r\":\n    @Event global\n    wait(time=1)\n    debug(g == 1)\n");
1191        let RuleEntry::Rule(rule) = &program.rules[0] else {
1192            panic!("expected rule");
1193        };
1194        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1195            panic!("expected expression statement");
1196        };
1197        let Expr::Call { args, .. } = expr else {
1198            panic!("expected a call, got {expr:?}");
1199        };
1200        let (keyword, span) = args[0].keyword.as_ref().expect("keyword argument");
1201        assert_eq!(keyword, "time");
1202        assert_eq!(span.start.line, 3);
1203        assert!(matches!(&args[0].value, Expr::Number { .. }));
1204
1205        let Stmt::Expr { expr, .. } = &rule.actions[1] else {
1206            panic!("expected expression statement");
1207        };
1208        let Expr::Call { args, .. } = expr else {
1209            panic!("expected a call, got {expr:?}");
1210        };
1211        assert!(args[0].keyword.is_none(), "comparisons are not keywords");
1212        assert!(matches!(&args[0].value, Expr::Binary { .. }));
1213    }
1214
1215    #[test]
1216    fn adjacent_string_literals_concatenate_and_preserve_span() {
1217        let program = parse_ok("rule \"r\":\n    @Event global\n    debug(\"one\" \"two\")\n");
1218        let RuleEntry::Rule(rule) = &program.rules[0] else {
1219            panic!("expected rule");
1220        };
1221        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1222            panic!("expected expression statement");
1223        };
1224        let Expr::Call { args, .. } = expr else {
1225            panic!("expected call");
1226        };
1227        let Expr::String { value, span } = &args[0].value else {
1228            panic!("expected concatenated string");
1229        };
1230        assert_eq!(value, "onetwo");
1231        assert_eq!(span.start.line, 3);
1232        assert_eq!(span.start.col, 11);
1233        assert_eq!(span.end.col, 22);
1234    }
1235
1236    #[test]
1237    fn multiline_adjacent_string_literals_concatenate_inside_group() {
1238        let program =
1239            parse_ok("rule \"r\":\n    @Event global\n    debug(\"one\"\n        \"two\")\n");
1240        let RuleEntry::Rule(rule) = &program.rules[0] else {
1241            panic!("expected rule");
1242        };
1243        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1244            panic!("expected expression statement");
1245        };
1246        let Expr::Call { args, .. } = expr else {
1247            panic!("expected call");
1248        };
1249        assert!(matches!(
1250            &args[0].value,
1251            Expr::String { value, .. } if value == "onetwo"
1252        ));
1253    }
1254
1255    #[test]
1256    fn newline_outside_group_keeps_adjacent_literals_as_statements() {
1257        let program = parse_ok("rule \"r\":\n    @Event global\n    \"one\"\n    \"two\"\n");
1258        let RuleEntry::Rule(rule) = &program.rules[0] else {
1259            panic!("expected rule");
1260        };
1261        assert_eq!(rule.actions.len(), 2);
1262    }
1263
1264    #[test]
1265    fn non_name_keyword_lhs_is_a_parse_error() {
1266        // `f(1 = 2)` is not a call argument form; rejected explicitly.
1267        let errors = parse_err("rule \"r\":\n    @Event global\n    debug(1 = 2)\n");
1268        assert!(!errors.is_empty());
1269        assert_eq!(errors[0].code, "parse-error");
1270    }
1271
1272    #[test]
1273    fn parses_member_call_on_call_result() {
1274        // `getPlayersInRadius(...).setStatusEffect(...)`: member access
1275        // followed by call arguments on a call result stays a receiver call.
1276        let program = parse_ok(
1277            "rule \"r\":\n    @Event eachPlayer\n    getPlayersInRadius(eventPlayer, 10).setStatusEffect(eventPlayer, 30)\n",
1278        );
1279        let RuleEntry::Rule(rule) = &program.rules[0] else {
1280            panic!("expected rule");
1281        };
1282        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1283            panic!("expected expression statement");
1284        };
1285        let Expr::ReceiverCall {
1286            receiver,
1287            name,
1288            args,
1289            ..
1290        } = &expr
1291        else {
1292            panic!("expected receiver call, got {expr:?}");
1293        };
1294        assert_eq!(name, "setStatusEffect");
1295        assert!(
1296            matches!(receiver.as_ref(), Expr::Call { name, .. } if name == "getPlayersInRadius"),
1297            "receiver must be the preceding call"
1298        );
1299        assert_eq!(args.len(), 2);
1300    }
1301
1302    #[test]
1303    fn member_without_call_is_not_a_call() {
1304        // `eventPlayer.moveSpeed` alone (no parentheses) stays a member
1305        // access; only a following `(` turns it into a receiver call.
1306        let program =
1307            parse_ok("rule \"r\":\n    @Event eachPlayer\n    x = eventPlayer.moveSpeed\n");
1308        let RuleEntry::Rule(rule) = &program.rules[0] else {
1309            panic!("expected rule");
1310        };
1311        let Stmt::Assign { value, .. } = &rule.actions[0] else {
1312            panic!("expected assignment");
1313        };
1314        assert!(matches!(
1315            &value,
1316            Expr::Member { member, .. } if member == "moveSpeed"
1317        ));
1318    }
1319
1320    #[test]
1321    fn parses_advanced_rule_annotations_with_source_arguments() {
1322        let program = parse_ok(
1323            "subroutine helper\ndef helper():\n    @Name \"renamed\"\n    @SuppressWarnings unusedVariable\n    pass\nrule \"r\":\n    @Event eachPlayer\n    @Team 1\n    @Hero dmon\n    @Disabled\n    @Delimiter\n    @NewPage \"Page\"\n    @SuppressWarnings unusedVariable\n    pass\n",
1324        );
1325        let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else {
1326            panic!("expected subroutine");
1327        };
1328        assert_eq!(annotations.len(), 2);
1329        let RuleEntry::Rule(rule) = &program.rules[1] else {
1330            panic!("expected rule");
1331        };
1332        assert!(rule.disabled);
1333        assert!(rule.delimiter);
1334        assert_eq!(rule.new_page.as_deref(), Some("Page"));
1335        assert_eq!(rule.annotations.len(), 7);
1336        assert_eq!(rule.annotations[1].args[0].text, "1");
1337        assert_eq!(rule.annotations[2].args[0].text, "dmon");
1338    }
1339
1340    #[test]
1341    fn decodes_unicode_escapes_in_names_and_ordinary_strings() {
1342        let program = parse_ok(
1343            r#"subroutine helper
1344def helper():
1345    @Name "helper\ufeffname"
1346    pass
1347rule "pa\ufeffssed":
1348    @Event global
1349    debug("pa\ufeffssed")
1350    debug(f"pa\ufeffssed {1}")
1351"#,
1352        );
1353        let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else {
1354            panic!("expected subroutine");
1355        };
1356        assert_eq!(annotations[0].args[0].text, "\"helper\u{feff}name\"");
1357
1358        let RuleEntry::Rule(rule) = &program.rules[1] else {
1359            panic!("expected rule");
1360        };
1361        assert_eq!(rule.name, "pa\u{feff}ssed");
1362        assert_eq!(rule.name_span.start.col, 7);
1363        assert_eq!(rule.name_span.end.col, 19);
1364        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1365            panic!("expected debug expression");
1366        };
1367        let Expr::Call { args, .. } = expr else {
1368            panic!("expected debug call");
1369        };
1370        assert!(matches!(
1371            &args[0].value,
1372            Expr::String { value, .. } if value == "pa\u{feff}ssed"
1373        ));
1374        let Stmt::Expr { expr, .. } = &rule.actions[1] else {
1375            panic!("expected formatted debug expression");
1376        };
1377        let Expr::Call { args, .. } = expr else {
1378            panic!("expected formatted debug call");
1379        };
1380        assert!(matches!(
1381            &args[0].value,
1382            Expr::StringModifier {
1383                format_text: Some(text), ..
1384            } if text == "pa\u{feff}ssed {0}"
1385        ));
1386    }
1387}