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