use std::collections::HashMap;
use crate::cst::{
Annotation, AnnotationArg, CallArg, Decl, DictEntry, Event, Expr, IfBranch, Program, Rule,
RuleEntry, Stmt, SwitchArm, TopLevel,
};
use crate::diag::{OpyError, Position, Span};
use crate::lexer::{Token, TokenKind, decode_string_escape, is_identifier};
#[derive(Debug, Default)]
pub struct ParseOutput {
pub program: Option<Program>,
pub errors: Vec<OpyError>,
}
pub fn parse(tokens: &[Token]) -> ParseOutput {
parse_with_options(tokens, false)
}
pub fn parse_with_options(tokens: &[Token], allow_macro_redeclaration: bool) -> ParseOutput {
let mut parser = Parser::new(tokens, allow_macro_redeclaration);
let program = parser.parse_program();
if parser.errors.is_empty() {
ParseOutput {
program: Some(program),
errors: Vec::new(),
}
} else {
ParseOutput {
program: None,
errors: parser.errors,
}
}
}
mod declarations;
mod definitions;
mod expressions;
mod statements;
struct Parser<'a> {
tokens: &'a [Token],
delimiter_depth: Vec<u32>,
newline_lines: HashMap<u32, Vec<(usize, u32)>>,
pos: usize,
errors: Vec<OpyError>,
allow_macro_redeclaration: bool,
last_statement_continued: bool,
last_colon_body_continued: bool,
open_if_indents: Vec<u32>,
workshop_source_reported: bool,
}
impl<'a> Parser<'a> {
fn new(tokens: &'a [Token], allow_macro_redeclaration: bool) -> Self {
let mut delimiter_depth = Vec::with_capacity(tokens.len() + 1);
delimiter_depth.push(0);
let mut depth = 0u32;
let mut newline_lines: HashMap<u32, Vec<(usize, u32)>> = HashMap::new();
for (index, token) in tokens.iter().enumerate() {
match token.kind {
TokenKind::LParen | TokenKind::LBracket | TokenKind::LBrace => depth += 1,
TokenKind::RParen | TokenKind::RBracket | TokenKind::RBrace => {
depth = depth.saturating_sub(1)
}
_ => {}
}
delimiter_depth.push(depth);
if token.kind == TokenKind::Newline {
let entries = newline_lines.entry(token.span.file).or_default();
let line = token
.layout
.start
.line
.max(entries.last().map_or(0, |&(_, max)| max));
entries.push((index, line));
}
}
Self {
tokens,
delimiter_depth,
newline_lines,
pos: 0,
errors: Vec::new(),
allow_macro_redeclaration,
last_statement_continued: false,
last_colon_body_continued: false,
open_if_indents: Vec::new(),
workshop_source_reported: false,
}
}
}
fn is_opy_word(text: &str) -> bool {
matches!(
text,
"and"
| "or"
| "not"
| "in"
| "lambda"
| "if"
| "elif"
| "else"
| "for"
| "while"
| "do"
| "switch"
| "del"
| "goto"
| "continue"
| "break"
| "return"
| "pass"
| "case"
| "default"
)
}
const BRACE_BLOCKS: &str = "a `{`/`}` brace block; OPY blocks open with `:` and indentation";
fn is_binary_operator(kind: TokenKind) -> bool {
matches!(
kind,
TokenKind::Plus
| TokenKind::Minus
| TokenKind::Star
| TokenKind::Slash
| TokenKind::Percent
| TokenKind::DoubleStar
| TokenKind::Eq
| TokenKind::Ne
| TokenKind::Lt
| TokenKind::Le
| TokenKind::Gt
| TokenKind::Ge
)
}
fn unquote_annotation_arg(text: &str) -> String {
text.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.unwrap_or(text)
.to_string()
}
impl Parser<'_> {
fn peek(&self) -> &Token {
&self.tokens[self.pos.min(self.tokens.len() - 1)]
}
fn peek_kind(&self) -> TokenKind {
self.peek().kind
}
fn peek_at(&self, offset: usize) -> &Token {
&self.tokens[(self.pos + offset).min(self.tokens.len() - 1)]
}
fn advance(&mut self) -> Token {
let token = self.tokens[self.pos.min(self.tokens.len() - 1)].clone();
if self.pos < self.tokens.len() - 1 {
self.pos += 1;
}
token
}
fn bump(&mut self) {
if self.pos < self.tokens.len() - 1 {
self.pos += 1;
}
}
fn newline_after_line(&self, file: u32, line: u32) -> bool {
let Some(entries) = self.newline_lines.get(&file) else {
return false;
};
let consumed = entries.partition_point(|&(index, _)| index < self.pos);
consumed > 0 && entries[consumed - 1].1 > line
}
fn skip_newlines(&mut self) {
while self.peek_kind() == TokenKind::Newline {
self.bump();
}
}
fn skip_expression_newlines(&mut self) {
if self.peek_kind() != TokenKind::Newline {
return;
}
let previous = self.tokens[..self.pos]
.iter()
.rev()
.find(|token| !matches!(token.kind, TokenKind::Indent(_)));
let previous_allows_continuation = previous.is_some_and(|token| {
matches!(
token.kind,
TokenKind::LParen
| TokenKind::LBracket
| TokenKind::LBrace
| TokenKind::Comma
| TokenKind::Colon
| TokenKind::Assign
) || is_binary_operator(token.kind)
|| (token.kind == TokenKind::Ident
&& matches!(token.text.as_str(), "and" | "or" | "in" | "not" | "if"))
});
let mut next = self.pos;
while self.tokens[next].kind == TokenKind::Newline {
next += 1;
}
let inside_delimiter_group = self.inside_delimiter_group();
let next_allows_continuation = is_binary_operator(self.tokens[next].kind)
|| (inside_delimiter_group
&& matches!(
self.tokens[next].kind,
TokenKind::LParen
| TokenKind::LBracket
| TokenKind::Dot
| TokenKind::RParen
| TokenKind::RBracket
| TokenKind::RBrace
))
|| (self.tokens[next].kind == TokenKind::Ident
&& matches!(
self.tokens[next].text.as_str(),
"and" | "or" | "in" | "not" | "else"
))
|| (inside_delimiter_group
&& self.tokens[next].kind == TokenKind::Ident
&& matches!(self.tokens[next].text.as_str(), "if" | "for"));
if previous_allows_continuation || next_allows_continuation {
self.skip_newlines();
}
}
fn is_ident(&self, text: &str) -> bool {
self.peek_kind() == TokenKind::Ident && self.peek().text == text
}
fn expect_ident(&mut self, what: &str) -> Result<String, ()> {
if self.peek_kind() == TokenKind::Ident {
Ok(self.advance().text)
} else {
self.error_at_current(format!("expected {what}"));
Err(())
}
}
fn expect(&mut self, kind: TokenKind, what: &str) -> Result<Token, ()> {
if self.peek_kind() == kind {
Ok(self.advance())
} else {
self.error_at_current(format!("expected {what}"));
Err(())
}
}
fn error_at_current(&mut self, message: String) {
let span = self.peek().span;
self.errors.push(OpyError::at("parse-error", message, span));
}
pub(super) fn workshop_construct(&self, top_level: bool) -> Option<(Span, String)> {
let mut end = self.pos;
while !matches!(self.tokens[end].kind, TokenKind::Newline | TokenKind::Eof) {
end += 1;
}
let line = &self.tokens[self.pos..end];
let first = line.first()?;
let last = line.last().expect("line is non-empty");
if last.kind == TokenKind::Semicolon {
return Some((
Span::new(first.span.file, first.span.start, last.span.end),
"a `;` statement terminator; OPY ends statements at the line".to_string(),
));
}
if !top_level {
return None;
}
match first.kind {
TokenKind::RBrace => {
return Some((
first.span,
"a `}` only closes a Workshop brace block; OPY blocks end by dedenting"
.to_string(),
));
}
TokenKind::LBrace => {
return Some((first.span, BRACE_BLOCKS.to_string()));
}
_ => {}
}
if first.kind != TokenKind::Ident {
return None;
}
if line
.get(1)
.is_some_and(|token| token.kind == TokenKind::LBrace)
&& !is_opy_word(&first.text)
{
return Some((
Span::new(first.span.file, first.span.start, line[1].span.end),
BRACE_BLOCKS.to_string(),
));
}
let mut words = 0;
while line
.get(words)
.is_some_and(|token| token.kind == TokenKind::Ident && !is_opy_word(&token.text))
{
words += 1;
}
if words >= 2
&& line
.get(words)
.is_some_and(|token| token.kind == TokenKind::LParen)
{
let name = line[..words]
.iter()
.map(|token| token.text.as_str())
.collect::<Vec<_>>()
.join(" ");
return Some((
Span::new(first.span.file, first.span.start, line[words].span.end),
format!("a multi-word call name `{name}`; OPY calls use a single name"),
));
}
None
}
pub(super) fn report_workshop_source(&mut self, span: Span, detail: &str) {
if self.workshop_source_reported {
return;
}
self.workshop_source_reported = true;
self.errors.push(OpyError::at(
"workshop-source",
format!("this looks like Workshop script, not OPY ({detail})"),
span,
));
}
pub(super) fn skip_workshop_construct(&mut self) {
let mut depth = 0u32;
loop {
match self.peek_kind() {
TokenKind::Eof => break,
TokenKind::Newline if depth == 0 => break,
TokenKind::LBrace => depth += 1,
TokenKind::RBrace => depth = depth.saturating_sub(1),
_ => {}
}
self.bump();
}
}
pub(super) fn expect_block_colon(&mut self, colon_context: &str) -> Result<(), ()> {
if self.peek_kind() == TokenKind::LBrace {
let span = self.peek().span;
self.report_workshop_source(span, BRACE_BLOCKS);
self.skip_workshop_construct();
return Err(());
}
self.expect(TokenKind::Colon, colon_context).map(|_| ())
}
fn parse_program(&mut self) -> Program {
let mut declarations = Vec::new();
let mut rules = Vec::new();
let mut top_level = Vec::new();
loop {
self.skip_newlines();
if self.peek_kind() == TokenKind::Eof {
break;
}
let rule_prefix = if self.peek_kind() == TokenKind::RulePrefixMarker {
Some(self.advance().text)
} else {
None
};
let declaration_count = declarations.len();
let rule_count = rules.len();
let ok = self.parse_top_level(&mut declarations, &mut rules, rule_prefix);
if ok {
if declarations.len() > declaration_count {
top_level.push(TopLevel::Declaration(
declarations
.last()
.expect("declaration was appended")
.clone(),
));
} else if rules.len() > rule_count {
top_level.push(TopLevel::Rule(
rules.last().expect("rule was appended").clone(),
));
}
}
if !ok {
self.recover_line();
}
}
Program {
declarations,
rules,
top_level,
settings: None,
}
}
fn parse_top_level(
&mut self,
declarations: &mut Vec<Decl>,
rules: &mut Vec<RuleEntry>,
rule_prefix: Option<String>,
) -> bool {
let token = self.peek();
if token.kind == TokenKind::Ident {
match token.text.as_str() {
"rule" => return self.parse_rule(rules, rule_prefix),
"def" => return self.parse_def(rules, rule_prefix),
"globalvar" => return self.parse_variable(declarations, true),
"playervar" => return self.parse_variable(declarations, false),
"subroutine" => return self.parse_subroutine(declarations),
"enum" => return self.parse_enum(declarations),
"macro" => return self.parse_macro(declarations),
_ => {}
}
}
if let Some((span, detail)) = self.workshop_construct(true) {
self.report_workshop_source(span, &detail);
self.skip_workshop_construct();
} else {
self.error_at_current(format!(
"expected a top-level declaration (rule/def/globalvar/playervar/subroutine/enum/macro) but found '{}'",
token.text
));
}
false
}
fn recover_line(&mut self) {
while self.peek_kind() != TokenKind::Newline && self.peek_kind() != TokenKind::Eof {
self.bump();
}
}
fn block_indent(&mut self, line_indent: u32) -> Option<u32> {
self.skip_newlines();
if self.peek_kind() == TokenKind::Eof {
self.error_at_current("expected an indented block".to_string());
return None;
}
let indent = self.peek().layout.start.col;
if indent <= line_indent {
self.error_at_current("expected an indented block after ':'".to_string());
return None;
}
Some(indent)
}
pub(super) fn expect_block_indent(
&mut self,
line_indent: u32,
colon_context: &str,
) -> Result<u32, ()> {
self.expect_block_colon(colon_context)?;
self.block_indent(line_indent).ok_or(())
}
fn expect_statement_end(&mut self, what: &str) -> Result<(), ()> {
let continued_line = self
.tokens
.get(self.pos.saturating_sub(1))
.is_some_and(|previous| self.peek().layout.start.line > previous.layout.end.line);
self.last_statement_continued = continued_line;
if matches!(self.peek_kind(), TokenKind::Newline | TokenKind::Eof) || continued_line {
Ok(())
} else {
self.error_at_current(format!("expected the end of {what}"));
Err(())
}
}
}
pub(crate) fn parse_expression_fragment(
text: &str,
file: u32,
origin: Position,
bounds: Option<Span>,
) -> Result<Expr, OpyError> {
let mut tokens = crate::lexer::lex(crate::lexer::LexInput {
file_id: file,
text,
})?;
for token in &mut tokens {
token.layout = shift_span(token.span, origin);
token.span = bounds.map_or(token.layout, |bounds| bounded_span(token.layout, bounds));
}
let mut parser = Parser::new(&tokens, false);
let expression = parser.parse_expr().map_err(|()| {
parser.errors.first().cloned().unwrap_or_else(|| {
OpyError::at(
"parse-error",
"invalid f-string expression",
Span::new(file, origin, origin),
)
})
})?;
if parser.peek_kind() != TokenKind::Eof {
parser.error_at_current("unexpected tokens in f-string interpolation".to_string());
}
parser.errors.into_iter().next().map_or(Ok(expression), Err)
}
fn shift_span(span: Span, origin: Position) -> Span {
Span::new(
span.file,
crate::diag::shift_position(span.start, origin),
crate::diag::shift_position(span.end, origin),
)
}
pub(crate) fn bounded_span(span: Span, bounds: Span) -> Span {
if span.file != bounds.file {
return span;
}
Span::new(
span.file,
span.start.clamp(bounds.start, bounds.end),
span.end.clamp(bounds.start, bounds.end),
)
}
fn is_string_modifier(text: &str) -> bool {
matches!(text, "f" | "w" | "l" | "b" | "c" | "t")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::{LexInput, lex};
fn parse_ok(text: &str) -> Program {
let tokens = lex(LexInput { file_id: 0, text }).unwrap();
let output = parse(&tokens);
assert!(
output.errors.is_empty(),
"unexpected errors: {:?}",
output.errors
);
output.program.unwrap()
}
fn parse_err(text: &str) -> Vec<OpyError> {
let tokens = lex(LexInput { file_id: 0, text }).unwrap();
parse(&tokens).errors
}
#[test]
fn parses_basic_rule() {
let program = parse_ok("rule \"setup\":\n @Event global\n disableInspector()\n");
assert_eq!(program.rules.len(), 1);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
assert_eq!(rule.name, "setup");
assert_eq!(rule.event.name, "global");
assert_eq!(rule.actions.len(), 1);
}
#[test]
fn parses_power_augmented_assignment() {
let program =
parse_ok("globalvar a\nrule \"r\":\n @Event global\n a = 2\n a **= 3\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Assign {
value,
target: assigned_target,
..
} = &rule.actions[1]
else {
panic!("expected an assignment");
};
let Expr::Binary {
op, left, right, ..
} = value
else {
panic!("expected a binary modification, got {value:?}");
};
assert_eq!(op, "**");
assert!(matches!(&**left, Expr::Name { .. }));
assert!(matches!(
assigned_target,
Expr::Name { name, .. } if name == "a"
));
assert!(matches!(right.as_ref(), Expr::Number { .. }));
}
#[test]
fn parses_postfix_increment_and_decrement_as_modifications() {
let program =
parse_ok("globalvar value\nrule \"r\":\n @Event global\n value++\n value--\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected a rule");
};
for (statement, expected_op) in [(&rule.actions[0], "+"), (&rule.actions[1], "-")] {
let Stmt::Assign { target, value, .. } = statement else {
panic!("expected a postfix assignment");
};
let Expr::Binary {
op, left, right, ..
} = value
else {
panic!("expected a synthetic modification value");
};
assert_eq!(op, expected_op);
let Expr::Name {
name: left_name, ..
} = left.as_ref()
else {
panic!("expected the target to be the modification's left operand");
};
let Expr::Name {
name: target_name, ..
} = target
else {
panic!("expected a name target");
};
assert_eq!(left_name, target_name);
assert!(
matches!(right.as_ref(), Expr::Number { value, text, .. } if *value == 1.0 && text == "1")
);
}
}
#[test]
fn rejects_prefix_increment_and_embedded_postfix_forms() {
for source in [
"globalvar value\nrule \"r\":\n @Event global\n ++value\n",
"globalvar value\nrule \"r\":\n @Event global\n value++++\n",
] {
let errors = parse_err(source);
assert!(!errors.is_empty());
assert!(errors.iter().all(|error| error.code == "parse-error"));
assert!(errors.iter().all(|error| error.span.is_some()));
}
}
#[test]
fn preserves_consecutive_unary_minus_expressions() {
let source = concat!(
"globalvar value = 0\n",
"globalvar B = 1\n",
"rule \"r\":\n",
" @Event global\n",
" value = --1\n",
" value = --B\n",
" value = B--1\n",
);
parse_ok(source);
}
#[test]
fn parses_control_flow() {
let program = parse_ok(
"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",
);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!();
};
assert!(matches!(rule.actions[0], Stmt::For { .. }));
let Stmt::For { body, .. } = &rule.actions[0] else {
panic!();
};
let Stmt::If {
branches, r#else, ..
} = &body[0]
else {
panic!();
};
assert_eq!(branches.len(), 2);
assert!(r#else.is_some());
let Stmt::While { body, .. } = &rule.actions[1] else {
panic!();
};
assert_eq!(body.len(), 2);
}
#[test]
fn parses_source_statement_surface() {
let program = parse_ok(concat!(
"globalvar value\n",
"rule \"r\":\n",
" @Event global\n",
" del value[1]\n",
" value min= 2\n",
" value max= 3\n",
" while value < 4:\n",
" continue\n",
" goto RULE_START\n",
" goto target\n",
" goto loc + value\n",
" target:\n",
));
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
assert!(matches!(rule.actions[0], Stmt::Delete { .. }));
for (statement, expected) in [(&rule.actions[1], "min"), (&rule.actions[2], "max")] {
let Stmt::Assign { value, .. } = statement else {
panic!("expected augmented assignment");
};
assert!(matches!(value, Expr::Binary { op, .. } if op == expected));
}
let Stmt::While { body, .. } = &rule.actions[3] else {
panic!("expected while");
};
assert!(matches!(body.as_slice(), [Stmt::Continue { .. }]));
assert!(matches!(
&rule.actions[4],
Stmt::Goto {
label: None,
offset: None,
rule_start: true,
..
}
));
assert!(matches!(
&rule.actions[5],
Stmt::Goto {
label: Some(label),
offset: None,
rule_start: false,
..
} if label == "target"
));
assert!(matches!(
&rule.actions[6],
Stmt::Goto {
label: None,
offset: Some(_),
rule_start: false,
..
}
));
assert!(matches!(&rule.actions[7], Stmt::Label { name, .. } if name == "target"));
}
#[test]
fn workshop_script_source_reports_one_diagnostic() {
let errors = parse_err(concat!(
"rule \"workshop style\" {\n",
" event {\n",
" Ongoing - Global;\n",
" }\n",
" actions {\n",
" Wait(1, Ignore Condition);\n",
" }\n",
"}\n",
));
assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
let error = &errors[0];
assert_eq!(error.code, "workshop-source");
assert_eq!(
error.span.expect("a source span"),
Span::new(0, Position::new(1, 23), Position::new(1, 24))
);
}
#[test]
fn workshop_script_forms_report_once() {
for source in [
"rule(\"x\") {\n event {\n Ongoing - Global;\n }\n}\n",
"rule \"r\":\n @Event global\n Ongoing - Global;\n",
"actions {\n Wait(1);\n}\n",
"rule \"r\":\n @Event global\n if x == 1 {\n pass\n }\n",
"rule \"r\":\n @Event global\n Wait(1);\n}\n",
"rule \"r\":\n @Event global\n a = 1;\n b = 2;\n",
] {
let errors = parse_err(source);
assert_eq!(
errors.len(),
1,
"expected one diagnostic for {source:?}, got {errors:?}"
);
assert_eq!(errors[0].code, "workshop-source");
}
}
#[test]
fn workshop_tells_only_apply_at_top_level() {
for source in [
"x y(1)\n", "x {}\n", "}\n",
] {
let errors = parse_err(source);
assert_eq!(
errors.len(),
1,
"expected one diagnostic for {source:?}, got {errors:?}"
);
assert_eq!(errors[0].code, "workshop-source");
}
parse_ok(
"globalvar x\nglobalvar y\nrule \"r\":\n @Event global\n x y(1)\n x {}\n if {1: 2}: pass\n",
);
let errors = parse_err("rule \"r\":\n @Event global\n x = {\n a\n }\n");
assert!(!errors.is_empty(), "malformed dict parsed cleanly");
assert!(errors.iter().all(|error| error.code == "parse-error"));
}
#[test]
fn workshop_style_action_call_reports_once() {
let errors = parse_err("Set Player Variable(eventPlayer, score, 1)\n");
assert_eq!(errors.len(), 1, "expected one diagnostic, got {errors:?}");
let error = &errors[0];
assert_eq!(error.code, "workshop-source");
assert_eq!(
error.span.expect("a source span").start,
Position::new(1, 1)
);
}
#[test]
fn ordinary_syntax_errors_keep_their_diagnostics() {
let errors = parse_err("rule no_quotes:\n");
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, "parse-error");
assert!(errors[0].message.contains("rule name string"));
assert_eq!(
errors[0].span.expect("a source span").start,
Position::new(1, 6)
);
parse_ok("globalvar x\nglobalvar y\nrule \"r\":\n @Event global\n x y\n");
}
#[test]
fn rejects_invalid_source_statement_forms() {
for source in [
"rule \"r\":\n @Event global\n del value\n",
"rule \"r\":\n @Event global\n goto\n",
"rule \"r\":\n @Event global\n goto loc\n",
"rule \"r\":\n @Event global\n goto target extra\n",
"rule \"r\":\n @Event global\n continue now\n",
"rule \"r\":\n @Event global\n A = 1; A = 2\n",
] {
let errors = parse_err(source);
assert!(!errors.is_empty(), "invalid form parsed: {source}");
assert!(errors.iter().all(|error| error.code == "parse-error"));
assert!(errors.iter().all(|error| error.span.is_some()));
}
}
#[test]
fn rejects_continued_inline_if_without_else() {
let errors = parse_err(concat!(
"globalvar value\n",
"macro nextHero():\n",
" value = 1\\\n",
" if value == 0: value = 2\\\n",
" value = 3\\\n",
));
assert!(errors.iter().any(|error| {
error.code == "parse-error" && error.message == "Found 'if', but no 'else'"
}));
}
#[test]
fn parses_syntax_constructs() {
let program = parse_ok(
"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",
);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
assert!(matches!(rule.actions[0], Stmt::Switch { .. }));
assert!(matches!(rule.actions[1], Stmt::Assign { .. }));
}
#[test]
fn rejects_incomplete_do_while_and_dictionary_entries() {
let errors = parse_err(
"rule \"r\":\n @Event global\n do:\n pass\n while\n x = {\"x\"}\n",
);
assert!(!errors.is_empty());
assert!(errors.iter().all(|error| error.code == "parse-error"));
}
#[test]
fn parses_explicit_enum_member_values() {
let program = parse_ok("enum EventType:\n BUFF = 0\n DEBUFF\n MECH = 2\n");
let Decl::Enum { members, .. } = &program.declarations[0] else {
panic!("expected enum");
};
assert_eq!(
members
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
["BUFF", "DEBUFF", "MECH"]
);
}
#[test]
fn parses_multi_line_array() {
let program = parse_ok(
"globalvar p\nrule \"r\":\n @Event global\n p = [\n vect(1, 0, 0),\n vect(2, 0, 0),\n ]\n",
);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!();
};
let Stmt::Assign { value, .. } = &rule.actions[0] else {
panic!();
};
let Expr::Array { elements, .. } = value else {
panic!("expected array, got {value:?}");
};
assert_eq!(elements.len(), 2);
}
#[test]
fn missing_colon_is_a_structured_error() {
let errors = parse_err("rule \"x\"\n @Event global\n");
assert!(!errors.is_empty());
assert_eq!(errors[0].code, "parse-error");
assert!(errors[0].span.is_some());
}
#[test]
fn def_and_macro_parse() {
let program = parse_ok(
"subroutine showStatus\n\nmacro VERSION = \"1.4.3\"\n\ndef showStatus():\n print(\"hi\")\n\nmacro double(value):\n value + value\n",
);
assert_eq!(program.declarations.len(), 3);
assert!(matches!(program.declarations[1], Decl::Constant { .. }));
assert!(matches!(program.declarations[2], Decl::Macro { .. }));
let Decl::Macro { args, body, .. } = &program.declarations[2] else {
panic!();
};
assert_eq!(args, &vec!["value".to_string()]);
assert_eq!(body.len(), 1);
}
#[test]
fn macro_and_enum_redeclarations_are_checked_at_ast_surfaces() {
let text = "enum Kind:\n First\n First\nmacro helper():\n pass\nmacro helper():\n pass\n";
let errors = parse_err(text);
assert_eq!(
errors
.iter()
.filter(|error| error.code == "macro-redeclaration")
.count(),
2
);
let tokens = lex(LexInput { file_id: 0, text }).unwrap();
let output = parse_with_options(&tokens, true);
assert!(
output.errors.is_empty(),
"unexpected errors: {:?}",
output.errors
);
assert!(output.program.is_some());
}
#[test]
fn multiple_errors_are_reported() {
let errors =
parse_err("rule \"a\"\n bad statement here\nrule \"b\"\n @Event global\n");
assert!(!errors.is_empty());
}
#[test]
fn precedence_parses_python_like() {
let program = parse_ok("globalvar x\nrule \"r\":\n @Event global\n x = 1 + 2 * 3\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!();
};
let Stmt::Assign { value, .. } = &rule.actions[0] else {
panic!();
};
let Expr::Binary {
op, left, right, ..
} = value
else {
panic!();
};
assert_eq!(op, "+");
let Expr::Binary { op: inner, .. } = right.as_ref() else {
panic!();
};
assert_eq!(inner, "*");
assert!(matches!(left.as_ref(), Expr::Number { .. }));
}
#[test]
fn parses_right_associative_conditional_expressions() {
let program = parse_ok(
"rule \"r\":\n @Event global\n debug(1 if true else 2 if false else 3)\n",
);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected a rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected an expression statement");
};
let Expr::Call { args, .. } = expr else {
panic!("expected a call");
};
let Expr::Conditional {
then_value,
condition,
else_value,
span,
} = &args[0].value
else {
panic!("expected a conditional expression");
};
assert!(matches!(then_value.as_ref(), Expr::Number { value, .. } if *value == 1.0));
assert!(matches!(condition.as_ref(), Expr::Bool { value: true, .. }));
assert!(matches!(
else_value.as_ref(),
Expr::Conditional { then_value, condition, else_value, .. }
if matches!(then_value.as_ref(), Expr::Number { value, .. } if *value == 2.0)
&& matches!(condition.as_ref(), Expr::Bool { value: false, .. })
&& matches!(else_value.as_ref(), Expr::Number { value, .. } if *value == 3.0)
));
assert_eq!(span.start.line, 3);
assert_eq!(span.start.col, 11);
}
#[test]
fn parses_parenthesized_nested_conditional_and_rejects_missing_else() {
let program = parse_ok(
"rule \"r\":\n @Event global\n debug((1 if true else 2) if false else 3)\n",
);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected a rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected an expression statement");
};
let Expr::Call { args, .. } = expr else {
panic!("expected a call");
};
assert!(matches!(
&args[0].value,
Expr::Conditional {
then_value,
condition,
else_value,
..
} if matches!(then_value.as_ref(), Expr::Conditional { .. })
&& matches!(condition.as_ref(), Expr::Bool { value: false, .. })
&& matches!(else_value.as_ref(), Expr::Number { value, .. } if *value == 3.0)
));
let errors = parse_err("rule \"r\":\n @Event global\n debug(1 if true)\n");
assert_eq!(errors[0].code, "parse-error");
assert!(errors[0].message.contains("expected `else`"));
}
#[test]
fn parses_receiver_calls() {
let program =
parse_ok("rule \"r\":\n @Event eachPlayer\n eventPlayer.setMoveSpeed(100)\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected expression statement, got {:?}", rule.actions[0]);
};
let Expr::ReceiverCall {
receiver,
name,
args,
..
} = &expr
else {
panic!("expected receiver call, got {expr:?}");
};
assert_eq!(name, "setMoveSpeed");
assert!(
matches!(receiver.as_ref(), Expr::Name { name, .. } if name == "eventPlayer"),
"receiver must be the eventPlayer name"
);
assert_eq!(args.len(), 1);
assert!(args[0].keyword.is_none(), "positional argument");
assert!(matches!(&args[0].value, Expr::Number { .. }));
}
#[test]
fn parses_keyword_arguments_with_name_spans() {
let program =
parse_ok("rule \"r\":\n @Event global\n wait(time=1)\n debug(g == 1)\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected expression statement");
};
let Expr::Call { args, .. } = expr else {
panic!("expected a call, got {expr:?}");
};
let (keyword, span) = args[0].keyword.as_ref().expect("keyword argument");
assert_eq!(keyword, "time");
assert_eq!(span.start.line, 3);
assert!(matches!(&args[0].value, Expr::Number { .. }));
let Stmt::Expr { expr, .. } = &rule.actions[1] else {
panic!("expected expression statement");
};
let Expr::Call { args, .. } = expr else {
panic!("expected a call, got {expr:?}");
};
assert!(args[0].keyword.is_none(), "comparisons are not keywords");
assert!(matches!(&args[0].value, Expr::Binary { .. }));
}
#[test]
fn adjacent_string_literals_concatenate_and_preserve_span() {
let program = parse_ok("rule \"r\":\n @Event global\n debug(\"one\" \"two\")\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected expression statement");
};
let Expr::Call { args, .. } = expr else {
panic!("expected call");
};
let Expr::String { value, span } = &args[0].value else {
panic!("expected concatenated string");
};
assert_eq!(value, "onetwo");
assert_eq!(span.start.line, 3);
assert_eq!(span.start.col, 11);
assert_eq!(span.end.col, 22);
}
#[test]
fn multiline_adjacent_string_literals_concatenate_inside_group() {
let program =
parse_ok("rule \"r\":\n @Event global\n debug(\"one\"\n \"two\")\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected expression statement");
};
let Expr::Call { args, .. } = expr else {
panic!("expected call");
};
assert!(matches!(
&args[0].value,
Expr::String { value, .. } if value == "onetwo"
));
}
#[test]
fn newline_outside_group_keeps_adjacent_literals_as_statements() {
let program = parse_ok("rule \"r\":\n @Event global\n \"one\"\n \"two\"\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
assert_eq!(rule.actions.len(), 2);
}
#[test]
fn non_name_keyword_lhs_is_a_parse_error() {
let errors = parse_err("rule \"r\":\n @Event global\n debug(1 = 2)\n");
assert!(!errors.is_empty());
assert_eq!(errors[0].code, "parse-error");
}
#[test]
fn parses_member_call_on_call_result() {
let program = parse_ok(
"rule \"r\":\n @Event eachPlayer\n getPlayersInRadius(eventPlayer, 10).setStatusEffect(eventPlayer, 30)\n",
);
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected expression statement");
};
let Expr::ReceiverCall {
receiver,
name,
args,
..
} = &expr
else {
panic!("expected receiver call, got {expr:?}");
};
assert_eq!(name, "setStatusEffect");
assert!(
matches!(receiver.as_ref(), Expr::Call { name, .. } if name == "getPlayersInRadius"),
"receiver must be the preceding call"
);
assert_eq!(args.len(), 2);
}
#[test]
fn member_without_call_is_not_a_call() {
let program =
parse_ok("rule \"r\":\n @Event eachPlayer\n x = eventPlayer.moveSpeed\n");
let RuleEntry::Rule(rule) = &program.rules[0] else {
panic!("expected rule");
};
let Stmt::Assign { value, .. } = &rule.actions[0] else {
panic!("expected assignment");
};
assert!(matches!(
&value,
Expr::Member { member, .. } if member == "moveSpeed"
));
}
#[test]
fn parses_advanced_rule_annotations_with_source_arguments() {
let program = parse_ok(
"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",
);
let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else {
panic!("expected subroutine");
};
assert_eq!(annotations.len(), 2);
let RuleEntry::Rule(rule) = &program.rules[1] else {
panic!("expected rule");
};
assert!(rule.disabled);
assert!(rule.delimiter);
assert_eq!(rule.new_page.as_deref(), Some("Page"));
assert_eq!(rule.annotations.len(), 7);
assert_eq!(rule.annotations[1].args[0].text, "1");
assert_eq!(rule.annotations[2].args[0].text, "dmon");
}
#[test]
fn decodes_unicode_escapes_in_names_and_ordinary_strings() {
let program = parse_ok(
r#"subroutine helper
def helper():
@Name "helper\ufeffname"
pass
rule "pa\ufeffssed":
@Event global
debug("pa\ufeffssed")
debug(f"pa\ufeffssed {1}")
"#,
);
let RuleEntry::SubroutineDef { annotations, .. } = &program.rules[0] else {
panic!("expected subroutine");
};
assert_eq!(annotations[0].args[0].text, "\"helper\u{feff}name\"");
let RuleEntry::Rule(rule) = &program.rules[1] else {
panic!("expected rule");
};
assert_eq!(rule.name, "pa\u{feff}ssed");
assert_eq!(rule.name_span.start.col, 7);
assert_eq!(rule.name_span.end.col, 19);
let Stmt::Expr { expr, .. } = &rule.actions[0] else {
panic!("expected debug expression");
};
let Expr::Call { args, .. } = expr else {
panic!("expected debug call");
};
assert!(matches!(
&args[0].value,
Expr::String { value, .. } if value == "pa\u{feff}ssed"
));
let Stmt::Expr { expr, .. } = &rule.actions[1] else {
panic!("expected formatted debug expression");
};
let Expr::Call { args, .. } = expr else {
panic!("expected formatted debug call");
};
assert!(matches!(
&args[0].value,
Expr::StringModifier {
format_text: Some(text), ..
} if text == "pa\u{feff}ssed {0}"
));
}
}