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                    .layout
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().layout.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().layout.start.line > previous.layout.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 expression fragment and shift its local token spans into the
558/// original source file. `bounds` limits the authored provenance of the
559/// fragment's tokens: inside an expanded string the interpolation has no
560/// authored extent, so derived positions clamp into the string's span
561/// rather than fabricating columns past it (#506).
562pub(crate) fn parse_expression_fragment(
563    text: &str,
564    file: u32,
565    origin: Position,
566    bounds: Option<Span>,
567) -> Result<Expr, OpyError> {
568    let mut tokens = crate::lexer::lex(crate::lexer::LexInput {
569        file_id: file,
570        text,
571    })?;
572    for token in &mut tokens {
573        token.layout = shift_span(token.span, origin);
574        token.span = bounds.map_or(token.layout, |bounds| bounded_span(token.layout, bounds));
575    }
576    let mut parser = Parser::new(&tokens, false);
577    let expression = parser.parse_expr().map_err(|()| {
578        parser.errors.first().cloned().unwrap_or_else(|| {
579            OpyError::at(
580                "parse-error",
581                "invalid f-string expression",
582                Span::new(file, origin, origin),
583            )
584        })
585    })?;
586    if parser.peek_kind() != TokenKind::Eof {
587        parser.error_at_current("unexpected tokens in f-string interpolation".to_string());
588    }
589    parser.errors.into_iter().next().map_or(Ok(expression), Err)
590}
591
592fn shift_span(span: Span, origin: Position) -> Span {
593    Span::new(
594        span.file,
595        crate::diag::shift_position(span.start, origin),
596        crate::diag::shift_position(span.end, origin),
597    )
598}
599
600pub(crate) fn bounded_span(span: Span, bounds: Span) -> Span {
601    if span.file != bounds.file {
602        return span;
603    }
604    Span::new(
605        span.file,
606        span.start.clamp(bounds.start, bounds.end),
607        span.end.clamp(bounds.start, bounds.end),
608    )
609}
610
611fn is_string_modifier(text: &str) -> bool {
612    matches!(text, "f" | "w" | "l" | "b" | "c" | "t")
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::lexer::{LexInput, lex};
619
620    fn parse_ok(text: &str) -> Program {
621        let tokens = lex(LexInput { file_id: 0, text }).unwrap();
622        let output = parse(&tokens);
623        assert!(
624            output.errors.is_empty(),
625            "unexpected errors: {:?}",
626            output.errors
627        );
628        output.program.unwrap()
629    }
630
631    fn parse_err(text: &str) -> Vec<OpyError> {
632        let tokens = lex(LexInput { file_id: 0, text }).unwrap();
633        parse(&tokens).errors
634    }
635
636    #[test]
637    fn parses_basic_rule() {
638        let program = parse_ok("rule \"setup\":\n    @Event global\n    disableInspector()\n");
639        assert_eq!(program.rules.len(), 1);
640        let RuleEntry::Rule(rule) = &program.rules[0] else {
641            panic!("expected rule");
642        };
643        assert_eq!(rule.name, "setup");
644        assert_eq!(rule.event.name, "global");
645        assert_eq!(rule.actions.len(), 1);
646    }
647
648    #[test]
649    fn parses_power_augmented_assignment() {
650        // The pinned OverPy 9.7.10 reference accepts `**=` as the power
651        // augmented assignment (`a **= b` ⇔ `a = a ** b`).
652        let program =
653            parse_ok("globalvar a\nrule \"r\":\n    @Event global\n    a = 2\n    a **= 3\n");
654        let RuleEntry::Rule(rule) = &program.rules[0] else {
655            panic!("expected rule");
656        };
657        let Stmt::Assign {
658            value,
659            target: assigned_target,
660            ..
661        } = &rule.actions[1]
662        else {
663            panic!("expected an assignment");
664        };
665        let Expr::Binary {
666            op, left, right, ..
667        } = value
668        else {
669            panic!("expected a binary modification, got {value:?}");
670        };
671        assert_eq!(op, "**");
672        assert!(matches!(&**left, Expr::Name { .. }));
673        assert!(matches!(
674            assigned_target,
675            Expr::Name { name, .. } if name == "a"
676        ));
677        assert!(matches!(right.as_ref(), Expr::Number { .. }));
678    }
679
680    #[test]
681    fn parses_postfix_increment_and_decrement_as_modifications() {
682        let program =
683            parse_ok("globalvar value\nrule \"r\":\n    @Event global\n    value++\n    value--\n");
684        let RuleEntry::Rule(rule) = &program.rules[0] else {
685            panic!("expected a rule");
686        };
687        for (statement, expected_op) in [(&rule.actions[0], "+"), (&rule.actions[1], "-")] {
688            let Stmt::Assign { target, value, .. } = statement else {
689                panic!("expected a postfix assignment");
690            };
691            let Expr::Binary {
692                op, left, right, ..
693            } = value
694            else {
695                panic!("expected a synthetic modification value");
696            };
697            assert_eq!(op, expected_op);
698            let Expr::Name {
699                name: left_name, ..
700            } = left.as_ref()
701            else {
702                panic!("expected the target to be the modification's left operand");
703            };
704            let Expr::Name {
705                name: target_name, ..
706            } = target
707            else {
708                panic!("expected a name target");
709            };
710            assert_eq!(left_name, target_name);
711            assert!(
712                matches!(right.as_ref(), Expr::Number { value, text, .. } if *value == 1.0 && text == "1")
713            );
714        }
715    }
716
717    #[test]
718    fn rejects_prefix_increment_and_embedded_postfix_forms() {
719        for source in [
720            "globalvar value\nrule \"r\":\n    @Event global\n    ++value\n",
721            "globalvar value\nrule \"r\":\n    @Event global\n    value++++\n",
722        ] {
723            let errors = parse_err(source);
724            assert!(!errors.is_empty());
725            assert!(errors.iter().all(|error| error.code == "parse-error"));
726            assert!(errors.iter().all(|error| error.span.is_some()));
727        }
728    }
729
730    #[test]
731    fn preserves_consecutive_unary_minus_expressions() {
732        let source = concat!(
733            "globalvar value = 0\n",
734            "globalvar B = 1\n",
735            "rule \"r\":\n",
736            "    @Event global\n",
737            "    value = --1\n",
738            "    value = --B\n",
739            "    value = B--1\n",
740        );
741        parse_ok(source);
742    }
743
744    #[test]
745    fn parses_control_flow() {
746        let program = parse_ok(
747            "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",
748        );
749        let RuleEntry::Rule(rule) = &program.rules[0] else {
750            panic!();
751        };
752        assert!(matches!(rule.actions[0], Stmt::For { .. }));
753        let Stmt::For { body, .. } = &rule.actions[0] else {
754            panic!();
755        };
756        let Stmt::If {
757            branches, r#else, ..
758        } = &body[0]
759        else {
760            panic!();
761        };
762        assert_eq!(branches.len(), 2);
763        assert!(r#else.is_some());
764        let Stmt::While { body, .. } = &rule.actions[1] else {
765            panic!();
766        };
767        assert_eq!(body.len(), 2);
768    }
769
770    #[test]
771    fn parses_source_statement_surface() {
772        let program = parse_ok(concat!(
773            "globalvar value\n",
774            "rule \"r\":\n",
775            "    @Event global\n",
776            "    del value[1]\n",
777            "    value min= 2\n",
778            "    value max= 3\n",
779            "    while value < 4:\n",
780            "        continue\n",
781            "    goto RULE_START\n",
782            "    goto target\n",
783            "    goto loc + value\n",
784            "    target:\n",
785        ));
786        let RuleEntry::Rule(rule) = &program.rules[0] else {
787            panic!("expected rule");
788        };
789        assert!(matches!(rule.actions[0], Stmt::Delete { .. }));
790        for (statement, expected) in [(&rule.actions[1], "min"), (&rule.actions[2], "max")] {
791            let Stmt::Assign { value, .. } = statement else {
792                panic!("expected augmented assignment");
793            };
794            assert!(matches!(value, Expr::Binary { op, .. } if op == expected));
795        }
796        let Stmt::While { body, .. } = &rule.actions[3] else {
797            panic!("expected while");
798        };
799        assert!(matches!(body.as_slice(), [Stmt::Continue { .. }]));
800        assert!(matches!(
801            &rule.actions[4],
802            Stmt::Goto {
803                label: None,
804                offset: None,
805                rule_start: true,
806                ..
807            }
808        ));
809        assert!(matches!(
810            &rule.actions[5],
811            Stmt::Goto {
812                label: Some(label),
813                offset: None,
814                rule_start: false,
815                ..
816            } if label == "target"
817        ));
818        assert!(matches!(
819            &rule.actions[6],
820            Stmt::Goto {
821                label: None,
822                offset: Some(_),
823                rule_start: false,
824                ..
825            }
826        ));
827        assert!(matches!(&rule.actions[7], Stmt::Label { name, .. } if name == "target"));
828    }
829
830    #[test]
831    fn workshop_script_source_reports_one_diagnostic() {
832        // opy-rs#420: pasted Workshop script is a different language surface.
833        // It must produce one `workshop-source` diagnostic at the first
834        // construct instead of a per-line `parse-error` cascade.
835        let errors = parse_err(concat!(
836            "rule \"workshop style\" {\n",
837            "    event {\n",
838            "        Ongoing - Global;\n",
839            "    }\n",
840            "    actions {\n",
841            "        Wait(1, Ignore Condition);\n",
842            "    }\n",
843            "}\n",
844        ));
845        assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
846        let error = &errors[0];
847        assert_eq!(error.code, "workshop-source");
848        assert_eq!(
849            error.span.expect("a source span"),
850            Span::new(0, Position::new(1, 23), Position::new(1, 24))
851        );
852    }
853
854    #[test]
855    fn workshop_script_forms_report_once() {
856        for source in [
857            // `rule("name")` + `;` statements + `Ident {` headers.
858            "rule(\"x\") {\n    event {\n        Ongoing - Global;\n    }\n}\n",
859            // A `;`-terminated statement inside an OPY rule.
860            "rule \"r\":\n    @Event global\n    Ongoing - Global;\n",
861            // An `ident {` section header at top level.
862            "actions {\n    Wait(1);\n}\n",
863            // `if`-with-braces is a brace-block tell, not a mistyped colon.
864            "rule \"r\":\n    @Event global\n    if x == 1 {\n        pass\n    }\n",
865            // A `;` tell followed by a `}` line: the second tell reaches
866            // dispatch and must be suppressed by the once-only flag, not by
867            // the first construct's brace skip.
868            "rule \"r\":\n    @Event global\n    Wait(1);\n}\n",
869            // Two `;` tells on consecutive lines: the second must dedup.
870            "rule \"r\":\n    @Event global\n    a = 1;\n    b = 2;\n",
871        ] {
872            let errors = parse_err(source);
873            assert_eq!(
874                errors.len(),
875                1,
876                "expected one diagnostic for {source:?}, got {errors:?}"
877            );
878            assert_eq!(errors[0].code, "workshop-source");
879        }
880    }
881
882    #[test]
883    fn workshop_tells_only_apply_at_top_level() {
884        // #420: the identifier-shaped tells only fire at top level, where no
885        // OPY declaration starts with a bare identifier. Inside a rule body
886        // the same token shapes are adjacent statements and stay plain OPY.
887        for source in [
888            // `x y(1)` is token-identical to `Set Player Variable(...)`.
889            "x y(1)\n", // `x {` is token-identical to `event {`.
890            "x {}\n",   // A stray `}` at top level closes nothing OPY-side.
891            "}\n",
892        ] {
893            let errors = parse_err(source);
894            assert_eq!(
895                errors.len(),
896                1,
897                "expected one diagnostic for {source:?}, got {errors:?}"
898            );
899            assert_eq!(errors[0].code, "workshop-source");
900        }
901        // In a rule body all of these parse: `x` + `y(1)`/`{}` juxtapose as
902        // statements, and a keyword's `{` is a dict literal.
903        parse_ok(
904            "globalvar x\nglobalvar y\nrule \"r\":\n    @Event global\n    x y(1)\n    x {}\n    if {1: 2}: pass\n",
905        );
906        // A `}` reached via malformed-dict recovery inside a body keeps the
907        // ordinary parse error, not a Workshop mislabel.
908        let errors = parse_err("rule \"r\":\n    @Event global\n    x = {\n        a\n    }\n");
909        assert!(!errors.is_empty(), "malformed dict parsed cleanly");
910        assert!(errors.iter().all(|error| error.code == "parse-error"));
911    }
912
913    #[test]
914    fn workshop_style_action_call_reports_once() {
915        // `Set Player Variable(...)` is the multi-word Workshop action shape;
916        // it only flags at top level, where a bare-identifier line is never a
917        // valid OPY declaration.
918        let errors = parse_err("Set Player Variable(eventPlayer, score, 1)\n");
919        assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
920        let error = &errors[0];
921        assert_eq!(error.code, "workshop-source");
922        assert_eq!(
923            error.span.expect("a source span").start,
924            Position::new(1, 1)
925        );
926    }
927
928    #[test]
929    fn ordinary_syntax_errors_keep_their_diagnostics() {
930        // #420: a plain OPY syntax error keeps its code, message, and span.
931        let errors = parse_err("rule no_quotes:\n");
932        assert_eq!(errors.len(), 1);
933        assert_eq!(errors[0].code, "parse-error");
934        // The message is not contractual; pin its content loosely.
935        assert!(errors[0].message.contains("rule name string"));
936        assert_eq!(
937            errors[0].span.expect("a source span").start,
938            Position::new(1, 6)
939        );
940        // Juxtaposed names without a call still parse as adjacent statements.
941        parse_ok("globalvar x\nglobalvar y\nrule \"r\":\n    @Event global\n    x y\n");
942    }
943
944    #[test]
945    fn rejects_invalid_source_statement_forms() {
946        for source in [
947            "rule \"r\":\n    @Event global\n    del value\n",
948            "rule \"r\":\n    @Event global\n    goto\n",
949            "rule \"r\":\n    @Event global\n    goto loc\n",
950            "rule \"r\":\n    @Event global\n    goto target extra\n",
951            "rule \"r\":\n    @Event global\n    continue now\n",
952            "rule \"r\":\n    @Event global\n    A = 1; A = 2\n",
953        ] {
954            let errors = parse_err(source);
955            assert!(!errors.is_empty(), "invalid form parsed: {source}");
956            assert!(errors.iter().all(|error| error.code == "parse-error"));
957            assert!(errors.iter().all(|error| error.span.is_some()));
958        }
959    }
960
961    #[test]
962    fn rejects_continued_inline_if_without_else() {
963        let errors = parse_err(concat!(
964            "globalvar value\n",
965            "macro nextHero():\n",
966            "    value = 1\\\n",
967            "    if value == 0: value = 2\\\n",
968            "    value = 3\\\n",
969        ));
970        assert!(errors.iter().any(|error| {
971            error.code == "parse-error" && error.message == "Found 'if', but no 'else'"
972        }));
973    }
974
975    #[test]
976    fn parses_syntax_constructs() {
977        let program = parse_ok(
978            "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",
979        );
980        let RuleEntry::Rule(rule) = &program.rules[0] else {
981            panic!("expected rule");
982        };
983        assert!(matches!(rule.actions[0], Stmt::Switch { .. }));
984        assert!(matches!(rule.actions[1], Stmt::Assign { .. }));
985    }
986
987    #[test]
988    fn rejects_incomplete_do_while_and_dictionary_entries() {
989        let errors = parse_err(
990            "rule \"r\":\n    @Event global\n    do:\n        pass\n    while\n    x = {\"x\"}\n",
991        );
992        assert!(!errors.is_empty());
993        assert!(errors.iter().all(|error| error.code == "parse-error"));
994    }
995
996    #[test]
997    fn parses_explicit_enum_member_values() {
998        let program = parse_ok("enum EventType:\n    BUFF = 0\n    DEBUFF\n    MECH = 2\n");
999        let Decl::Enum { members, .. } = &program.declarations[0] else {
1000            panic!("expected enum");
1001        };
1002        assert_eq!(
1003            members
1004                .iter()
1005                .map(|(name, _)| name.as_str())
1006                .collect::<Vec<_>>(),
1007            ["BUFF", "DEBUFF", "MECH"]
1008        );
1009    }
1010
1011    #[test]
1012    fn parses_multi_line_array() {
1013        let program = parse_ok(
1014            "globalvar p\nrule \"r\":\n    @Event global\n    p = [\n        vect(1, 0, 0),\n        vect(2, 0, 0),\n    ]\n",
1015        );
1016        let RuleEntry::Rule(rule) = &program.rules[0] else {
1017            panic!();
1018        };
1019        let Stmt::Assign { value, .. } = &rule.actions[0] else {
1020            panic!();
1021        };
1022        let Expr::Array { elements, .. } = value else {
1023            panic!("expected array, got {value:?}");
1024        };
1025        assert_eq!(elements.len(), 2);
1026    }
1027
1028    #[test]
1029    fn missing_colon_is_a_structured_error() {
1030        let errors = parse_err("rule \"x\"\n    @Event global\n");
1031        assert!(!errors.is_empty());
1032        assert_eq!(errors[0].code, "parse-error");
1033        assert!(errors[0].span.is_some());
1034    }
1035
1036    #[test]
1037    fn def_and_macro_parse() {
1038        let program = parse_ok(
1039            "subroutine showStatus\n\nmacro VERSION = \"1.4.3\"\n\ndef showStatus():\n    print(\"hi\")\n\nmacro double(value):\n    value + value\n",
1040        );
1041        assert_eq!(program.declarations.len(), 3);
1042        assert!(matches!(program.declarations[1], Decl::Constant { .. }));
1043        assert!(matches!(program.declarations[2], Decl::Macro { .. }));
1044        let Decl::Macro { args, body, .. } = &program.declarations[2] else {
1045            panic!();
1046        };
1047        assert_eq!(args, &vec!["value".to_string()]);
1048        assert_eq!(body.len(), 1);
1049    }
1050
1051    #[test]
1052    fn macro_and_enum_redeclarations_are_checked_at_ast_surfaces() {
1053        let text = "enum Kind:\n    First\n    First\nmacro helper():\n    pass\nmacro helper():\n    pass\n";
1054        let errors = parse_err(text);
1055        assert_eq!(
1056            errors
1057                .iter()
1058                .filter(|error| error.code == "macro-redeclaration")
1059                .count(),
1060            2
1061        );
1062
1063        let tokens = lex(LexInput { file_id: 0, text }).unwrap();
1064        let output = parse_with_options(&tokens, true);
1065        assert!(
1066            output.errors.is_empty(),
1067            "unexpected errors: {:?}",
1068            output.errors
1069        );
1070        assert!(output.program.is_some());
1071    }
1072
1073    #[test]
1074    fn multiple_errors_are_reported() {
1075        let errors =
1076            parse_err("rule \"a\"\n    bad statement here\nrule \"b\"\n    @Event global\n");
1077        assert!(!errors.is_empty());
1078    }
1079
1080    #[test]
1081    fn precedence_parses_python_like() {
1082        let program = parse_ok("globalvar x\nrule \"r\":\n    @Event global\n    x = 1 + 2 * 3\n");
1083        let RuleEntry::Rule(rule) = &program.rules[0] else {
1084            panic!();
1085        };
1086        let Stmt::Assign { value, .. } = &rule.actions[0] else {
1087            panic!();
1088        };
1089        let Expr::Binary {
1090            op, left, right, ..
1091        } = value
1092        else {
1093            panic!();
1094        };
1095        assert_eq!(op, "+");
1096        let Expr::Binary { op: inner, .. } = right.as_ref() else {
1097            panic!();
1098        };
1099        assert_eq!(inner, "*");
1100        assert!(matches!(left.as_ref(), Expr::Number { .. }));
1101    }
1102
1103    #[test]
1104    fn parses_right_associative_conditional_expressions() {
1105        let program = parse_ok(
1106            "rule \"r\":\n    @Event global\n    debug(1 if true else 2 if false else 3)\n",
1107        );
1108        let RuleEntry::Rule(rule) = &program.rules[0] else {
1109            panic!("expected a rule");
1110        };
1111        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1112            panic!("expected an expression statement");
1113        };
1114        let Expr::Call { args, .. } = expr else {
1115            panic!("expected a call");
1116        };
1117        let Expr::Conditional {
1118            then_value,
1119            condition,
1120            else_value,
1121            span,
1122        } = &args[0].value
1123        else {
1124            panic!("expected a conditional expression");
1125        };
1126        assert!(matches!(then_value.as_ref(), Expr::Number { value, .. } if *value == 1.0));
1127        assert!(matches!(condition.as_ref(), Expr::Bool { value: true, .. }));
1128        assert!(matches!(
1129            else_value.as_ref(),
1130            Expr::Conditional { then_value, condition, else_value, .. }
1131                if matches!(then_value.as_ref(), Expr::Number { value, .. } if *value == 2.0)
1132                    && matches!(condition.as_ref(), Expr::Bool { value: false, .. })
1133                    && matches!(else_value.as_ref(), Expr::Number { value, .. } if *value == 3.0)
1134        ));
1135        assert_eq!(span.start.line, 3);
1136        assert_eq!(span.start.col, 11);
1137    }
1138
1139    #[test]
1140    fn parses_parenthesized_nested_conditional_and_rejects_missing_else() {
1141        let program = parse_ok(
1142            "rule \"r\":\n    @Event global\n    debug((1 if true else 2) if false else 3)\n",
1143        );
1144        let RuleEntry::Rule(rule) = &program.rules[0] else {
1145            panic!("expected a rule");
1146        };
1147        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1148            panic!("expected an expression statement");
1149        };
1150        let Expr::Call { args, .. } = expr else {
1151            panic!("expected a call");
1152        };
1153        assert!(matches!(
1154            &args[0].value,
1155            Expr::Conditional {
1156                then_value,
1157                condition,
1158                else_value,
1159                ..
1160            } if matches!(then_value.as_ref(), Expr::Conditional { .. })
1161                && matches!(condition.as_ref(), Expr::Bool { value: false, .. })
1162                && matches!(else_value.as_ref(), Expr::Number { value, .. } if *value == 3.0)
1163        ));
1164
1165        let errors = parse_err("rule \"r\":\n    @Event global\n    debug(1 if true)\n");
1166        assert_eq!(errors[0].code, "parse-error");
1167        assert!(errors[0].message.contains("expected `else`"));
1168    }
1169
1170    #[test]
1171    fn parses_receiver_calls() {
1172        // `eventPlayer.setMoveSpeed(100)` is a receiver call: postfix `.`
1173        // member access followed by call arguments (#104).
1174        let program =
1175            parse_ok("rule \"r\":\n    @Event eachPlayer\n    eventPlayer.setMoveSpeed(100)\n");
1176        let RuleEntry::Rule(rule) = &program.rules[0] else {
1177            panic!("expected rule");
1178        };
1179        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1180            panic!("expected expression statement, got {:?}", rule.actions[0]);
1181        };
1182        let Expr::ReceiverCall {
1183            receiver,
1184            name,
1185            args,
1186            ..
1187        } = &expr
1188        else {
1189            panic!("expected receiver call, got {expr:?}");
1190        };
1191        assert_eq!(name, "setMoveSpeed");
1192        assert!(
1193            matches!(receiver.as_ref(), Expr::Name { name, .. } if name == "eventPlayer"),
1194            "receiver must be the eventPlayer name"
1195        );
1196        assert_eq!(args.len(), 1);
1197        assert!(args[0].keyword.is_none(), "positional argument");
1198        assert!(matches!(&args[0].value, Expr::Number { .. }));
1199    }
1200
1201    #[test]
1202    fn parses_keyword_arguments_with_name_spans() {
1203        // `name = expr` call arguments are keyword arguments carrying the
1204        // name token's exact span (issue #110); comparisons stay positional.
1205        let program =
1206            parse_ok("rule \"r\":\n    @Event global\n    wait(time=1)\n    debug(g == 1)\n");
1207        let RuleEntry::Rule(rule) = &program.rules[0] else {
1208            panic!("expected rule");
1209        };
1210        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1211            panic!("expected expression statement");
1212        };
1213        let Expr::Call { args, .. } = expr else {
1214            panic!("expected a call, got {expr:?}");
1215        };
1216        let (keyword, span) = args[0].keyword.as_ref().expect("keyword argument");
1217        assert_eq!(keyword, "time");
1218        assert_eq!(span.start.line, 3);
1219        assert!(matches!(&args[0].value, Expr::Number { .. }));
1220
1221        let Stmt::Expr { expr, .. } = &rule.actions[1] else {
1222            panic!("expected expression statement");
1223        };
1224        let Expr::Call { args, .. } = expr else {
1225            panic!("expected a call, got {expr:?}");
1226        };
1227        assert!(args[0].keyword.is_none(), "comparisons are not keywords");
1228        assert!(matches!(&args[0].value, Expr::Binary { .. }));
1229    }
1230
1231    #[test]
1232    fn adjacent_string_literals_concatenate_and_preserve_span() {
1233        let program = parse_ok("rule \"r\":\n    @Event global\n    debug(\"one\" \"two\")\n");
1234        let RuleEntry::Rule(rule) = &program.rules[0] else {
1235            panic!("expected rule");
1236        };
1237        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1238            panic!("expected expression statement");
1239        };
1240        let Expr::Call { args, .. } = expr else {
1241            panic!("expected call");
1242        };
1243        let Expr::String { value, span } = &args[0].value else {
1244            panic!("expected concatenated string");
1245        };
1246        assert_eq!(value, "onetwo");
1247        assert_eq!(span.start.line, 3);
1248        assert_eq!(span.start.col, 11);
1249        assert_eq!(span.end.col, 22);
1250    }
1251
1252    #[test]
1253    fn multiline_adjacent_string_literals_concatenate_inside_group() {
1254        let program =
1255            parse_ok("rule \"r\":\n    @Event global\n    debug(\"one\"\n        \"two\")\n");
1256        let RuleEntry::Rule(rule) = &program.rules[0] else {
1257            panic!("expected rule");
1258        };
1259        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1260            panic!("expected expression statement");
1261        };
1262        let Expr::Call { args, .. } = expr else {
1263            panic!("expected call");
1264        };
1265        assert!(matches!(
1266            &args[0].value,
1267            Expr::String { value, .. } if value == "onetwo"
1268        ));
1269    }
1270
1271    #[test]
1272    fn newline_outside_group_keeps_adjacent_literals_as_statements() {
1273        let program = parse_ok("rule \"r\":\n    @Event global\n    \"one\"\n    \"two\"\n");
1274        let RuleEntry::Rule(rule) = &program.rules[0] else {
1275            panic!("expected rule");
1276        };
1277        assert_eq!(rule.actions.len(), 2);
1278    }
1279
1280    #[test]
1281    fn non_name_keyword_lhs_is_a_parse_error() {
1282        // `f(1 = 2)` is not a call argument form; rejected explicitly.
1283        let errors = parse_err("rule \"r\":\n    @Event global\n    debug(1 = 2)\n");
1284        assert!(!errors.is_empty());
1285        assert_eq!(errors[0].code, "parse-error");
1286    }
1287
1288    #[test]
1289    fn parses_member_call_on_call_result() {
1290        // `getPlayersInRadius(...).setStatusEffect(...)`: member access
1291        // followed by call arguments on a call result stays a receiver call.
1292        let program = parse_ok(
1293            "rule \"r\":\n    @Event eachPlayer\n    getPlayersInRadius(eventPlayer, 10).setStatusEffect(eventPlayer, 30)\n",
1294        );
1295        let RuleEntry::Rule(rule) = &program.rules[0] else {
1296            panic!("expected rule");
1297        };
1298        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1299            panic!("expected expression statement");
1300        };
1301        let Expr::ReceiverCall {
1302            receiver,
1303            name,
1304            args,
1305            ..
1306        } = &expr
1307        else {
1308            panic!("expected receiver call, got {expr:?}");
1309        };
1310        assert_eq!(name, "setStatusEffect");
1311        assert!(
1312            matches!(receiver.as_ref(), Expr::Call { name, .. } if name == "getPlayersInRadius"),
1313            "receiver must be the preceding call"
1314        );
1315        assert_eq!(args.len(), 2);
1316    }
1317
1318    #[test]
1319    fn member_without_call_is_not_a_call() {
1320        // `eventPlayer.moveSpeed` alone (no parentheses) stays a member
1321        // access; only a following `(` turns it into a receiver call.
1322        let program =
1323            parse_ok("rule \"r\":\n    @Event eachPlayer\n    x = eventPlayer.moveSpeed\n");
1324        let RuleEntry::Rule(rule) = &program.rules[0] else {
1325            panic!("expected rule");
1326        };
1327        let Stmt::Assign { value, .. } = &rule.actions[0] else {
1328            panic!("expected assignment");
1329        };
1330        assert!(matches!(
1331            &value,
1332            Expr::Member { member, .. } if member == "moveSpeed"
1333        ));
1334    }
1335
1336    #[test]
1337    fn parses_advanced_rule_annotations_with_source_arguments() {
1338        let program = parse_ok(
1339            "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",
1340        );
1341        let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else {
1342            panic!("expected subroutine");
1343        };
1344        assert_eq!(annotations.len(), 2);
1345        let RuleEntry::Rule(rule) = &program.rules[1] else {
1346            panic!("expected rule");
1347        };
1348        assert!(rule.disabled);
1349        assert!(rule.delimiter);
1350        assert_eq!(rule.new_page.as_deref(), Some("Page"));
1351        assert_eq!(rule.annotations.len(), 7);
1352        assert_eq!(rule.annotations[1].args[0].text, "1");
1353        assert_eq!(rule.annotations[2].args[0].text, "dmon");
1354    }
1355
1356    #[test]
1357    fn decodes_unicode_escapes_in_names_and_ordinary_strings() {
1358        let program = parse_ok(
1359            r#"subroutine helper
1360def helper():
1361    @Name "helper\ufeffname"
1362    pass
1363rule "pa\ufeffssed":
1364    @Event global
1365    debug("pa\ufeffssed")
1366    debug(f"pa\ufeffssed {1}")
1367"#,
1368        );
1369        let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else {
1370            panic!("expected subroutine");
1371        };
1372        assert_eq!(annotations[0].args[0].text, "\"helper\u{feff}name\"");
1373
1374        let RuleEntry::Rule(rule) = &program.rules[1] else {
1375            panic!("expected rule");
1376        };
1377        assert_eq!(rule.name, "pa\u{feff}ssed");
1378        assert_eq!(rule.name_span.start.col, 7);
1379        assert_eq!(rule.name_span.end.col, 19);
1380        let Stmt::Expr { expr, .. } = &rule.actions[0] else {
1381            panic!("expected debug expression");
1382        };
1383        let Expr::Call { args, .. } = expr else {
1384            panic!("expected debug call");
1385        };
1386        assert!(matches!(
1387            &args[0].value,
1388            Expr::String { value, .. } if value == "pa\u{feff}ssed"
1389        ));
1390        let Stmt::Expr { expr, .. } = &rule.actions[1] else {
1391            panic!("expected formatted debug expression");
1392        };
1393        let Expr::Call { args, .. } = expr else {
1394            panic!("expected formatted debug call");
1395        };
1396        assert!(matches!(
1397            &args[0].value,
1398            Expr::StringModifier {
1399                format_text: Some(text), ..
1400            } if text == "pa\u{feff}ssed {0}"
1401        ));
1402    }
1403}