use super::common::*;
use luau_common::flags;
use luau_syntax::ast::{AstNodeRef, AstVisitor, Block};
fn with_recover<R>(source: &str, f: impl for<'ast> FnOnce(ParseResult<'ast>) -> R) -> R {
with_recover_options(source, ParseOptions::default(), f)
}
fn with_recover_options<R>(
source: &str,
options: ParseOptions,
f: impl for<'ast> FnOnce(ParseResult<'ast>) -> R,
) -> R {
with_parse(source, options, |result| f(result.unwrap()))
}
fn with_recover_error_options<R>(
source: &str,
options: ParseOptions,
f: impl FnOnce(ParseErrors) -> R,
) -> R {
with_parse(source, options, |result| f(result.unwrap_err()))
}
#[derive(Default)]
struct CountAstNodes {
count: usize,
}
impl AstVisitor for CountAstNodes {
fn visit_node(&mut self, _node: AstNodeRef<'_, '_>) -> bool {
self.count += 1;
true
}
}
fn ast_node_count(root: Block) -> usize {
let mut counter = CountAstNodes::default();
root.visit(&mut counter);
counter.count
}
fn first_call_argument<'a, 'ast>(result: &'a ParseResult<'ast>) -> &'a Expression<'ast> {
let [statement] = statement_kinds(result.root.as_slice()).exact();
let expression = statement
.as_expression()
.expect("expected expression statement")
.expr;
let ExpressionKind::Call { args, .. } = expression.kind() else {
panic!("expected call expression");
};
args.first().expect("expected call argument")
}
fn assert_incomplete_interpolation_result<'a, 'ast>(
result: &'a ParseResult<'ast>,
expected_end: Position,
expected_message: &str,
expected_error_location: Location,
) {
let argument = first_call_argument(result);
let ExpressionKind::InterpString {
strings: _,
expressions,
} = argument.kind()
else {
panic!("expected interpolated string expression");
};
assert_eq!(expressions.len(), 2);
assert_eq!(argument.location.begin, pos!(0, 6));
assert_eq!(argument.location.end, expected_end);
assert_eq!(result.metadata.errors.len(), 2);
assert_eq!(result.metadata.errors[0].message, expected_message);
assert_eq!(result.metadata.errors[0].location, expected_error_location);
}
#[derive(Default)]
struct FirstTableItemCount {
count: Option<usize>,
}
impl AstVisitor for FirstTableItemCount {
fn visit_expression(&mut self, expression: Expression) -> bool {
if self.count.is_none()
&& let ExpressionKind::Table { items } = expression.kind()
{
self.count = Some(items.len());
}
true
}
}
#[test]
fn multiple_parse_errors() {
with_recover(
r#"
local a = 3 * (
return a +
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 2);
},
);
}
#[test]
fn statement_error_recovery_expected() {
with_recover(
r#"
function a(a, b) return a + b end
some
a(2, 5)
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
},
);
}
#[test]
fn statement_error_recovery_unexpected() {
with_recover("+", |result| {
assert_eq!(result.metadata.errors.len(), 1);
});
}
#[test]
fn extra_token_in_consume() {
with_recover(
r#"
function test + (a, f) return a + f end
return test(2, 3)
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].message,
"Expected '(' when parsing function, got '+'"
);
},
);
}
#[test]
fn extra_token_in_consume_match() {
with_recover(
r#"
function test(a, f+) return a + f end
return test(2, 3)
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].message,
"Expected ')' (to close '(' at column 14), got '+'"
);
},
);
}
#[test]
fn extra_token_in_consume_match_end() {
with_recover(
r#"
if true then
return 12
then
end
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].message,
"Expected 'end' (to close 'then' at line 2), got 'then'"
);
},
);
}
#[test]
fn recovery_error_limit_1() {
let _limit = flags::LuauParseErrorLimit.scoped(1);
with_recover_error_options("local a = ", ParseOptions::default(), |errors| {
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].message.to_string(), errors.to_string());
});
}
#[test]
fn recovery_error_limit_2() {
let _limit = flags::LuauParseErrorLimit.scoped(2);
with_recover_error_options("escape escape escape", ParseOptions::default(), |errors| {
assert_eq!(errors.len(), 3);
assert_eq!(errors[2].to_string(), "Reached error limit (2)");
assert_eq!(errors.to_string(), "3 parse errors");
});
}
#[test]
fn recovery_of_parenthesized_expressions() {
for (source_with_errors, source, expected_errors) in [
(
"function foo(a, b. c) return a + b end",
"function foo(a, b) return a + b end",
1,
),
(
"function foo(a, b: { a: number, b: number. c:number }) return a + b end",
"function foo(a, b: { a: number, b: number }) return a + b end",
1,
),
(
"function foo(a, b): (number -> number return a + b end",
"function foo(a, b): (number) -> number return a + b end",
1,
),
(
"function foo(a, b): (number, number -> number return a + b end",
"function foo(a, b): (number) -> number return a + b end",
1,
),
(
"function foo(a, b): (number; number) -> number return a + b end",
"function foo(a, b): (number) -> number return a + b end",
1,
),
(
"function foo(a, b): (number, number return a + b end",
"function foo(a, b): (number, number) end",
1,
),
(
"local function foo(a, b): (number, number return a + b end",
"local function foo(a, b): (number, number) end",
1,
),
(
"type F = (number, number -> number",
"type F = (number, number) -> number",
1,
),
(
"function foo(a, b: { a: number, b: number) return a + b end",
"function foo(a, b: { a: number, b: number }) return a + b end",
1,
),
(
"function foo(a, b: { [number: number}) return a + b end",
"function foo(a, b: { [number]: number}) return a + b end",
1,
),
(
"local n: (string | number = 2",
"local n: (string | number) = 2",
1,
),
(
r#"
function foo(a, b
return a + b
end
"#,
"function foo(a, b) return a + b end",
1,
),
] {
with_recover(source_with_errors, |recovered| {
assert_eq!(
recovered.metadata.errors.len(),
expected_errors,
"{source_with_errors}"
);
with_parse(source, ParseOptions::default(), |expected| {
let expected = expected.unwrap();
assert_eq!(
ast_node_count(recovered.root),
ast_node_count(expected.root),
"{source_with_errors}"
);
});
});
}
}
#[test]
fn incomplete_method_call() {
with_recover(
r#"
function howdy()
return game:
end
"#,
|result| {
let [statement] = statement_kinds(result.root.as_slice()).exact();
let function = statement
.as_function_declaration()
.expect("expected recovered function declaration")
.function;
let [statement] = statement_kinds(function.body.as_slice()).exact();
assert_eq!(statement.tag, StatementTag::Return);
assert!(function.location.end > function.body.location().end);
},
);
}
#[test]
fn incomplete_method_call_2() {
with_recover(
r#"
local game = { GetService=function(s) return 'hello' end }
function a()
game:a
end
"#,
|result| {
assert_eq!(result.root.len(), 2);
let function = result.root[1]
.as_function_declaration()
.expect("expected recovered function declaration")
.function;
let [statement] = statement_kinds(function.body.as_slice()).exact();
assert_eq!(statement.tag, StatementTag::Error);
assert!(function.location.end > function.body.location().end);
},
);
}
#[test]
fn incomplete_method_call_still_yields_an_astexprindexname() {
with_recover(
r#"
game:
"#,
|result| {
let [statement] = statement_kinds(result.root.as_slice()).exact();
let error = statement
.as_error()
.expect("expected recovered error statement");
let [expression] = error.expressions else {
panic!("expected recovered error expression");
};
let ExpressionKind::Error {
expressions: nested,
..
} = expression.kind()
else {
panic!("expected recovered error expression");
};
let [nested] = nested else {
panic!("expected recovered index-name expression");
};
assert!(matches!(nested.kind(), ExpressionKind::IndexName { .. }));
},
);
}
#[test]
fn recover_confusables() {
for (source, expected) in [
("local a = 4 != 10", "Unexpected '!='; did you mean '~='?"),
(
"local a = true && false",
"Unexpected '&&'; did you mean 'and'?",
),
(
"local a = false || true",
"Unexpected '||'; did you mean 'or'?",
),
("local a = !false", "Unexpected '!'; did you mean 'not'?"),
(
"local a = 4 ! = 10",
"Expected identifier when parsing expression, got '!'",
),
(
"local a = true & & false",
"Expected identifier when parsing expression, got '&'",
),
(
"local a = false | | true",
"Expected identifier when parsing expression, got '|'",
),
] {
with_recover(source, |result| {
assert_eq!(result.metadata.errors[0].message, expected, "{source}");
});
}
}
#[test]
fn empty_function_type_error_recovery() {
for (source, expected) in [
(
r#"
type Fn = (
any,
string | number | ()
) -> any
"#,
"Expected '->' after '()' when parsing function type; did you mean 'nil'?",
),
(
"type Fn = (any, string | number | (number, number)) -> any",
"Expected '->' when parsing function type, got ')'",
),
(
"type Fn = (any, string | number | <a>()) -> any",
"Expected '->' when parsing function type, got ')'",
),
(
"type Fn = (any, string | number | <a...>()) -> any",
"Expected '->' when parsing function type, got ')'",
),
] {
with_recover(source, |result| {
assert_eq!(result.metadata.errors[0].message, expected, "{source}");
});
}
}
#[test]
fn generic_type_list_recovery() {
with_recover(
r#"
local function foo<T..., U>(a: U, ...: T...): (U, ...T) return a, ... end
return foo(1, 2 -- to check for a second error after recovery
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 2);
assert_eq!(
result.metadata.errors[0].message,
"Generic types come before generic type packs"
);
},
);
}
#[test]
fn recover_index_name_keyword() {
for source in [
r#"
local b
local a = b.do
"#,
r#"
local b
local a = b.
do end
"#,
] {
with_recover(source, |result| {
assert_eq!(result.metadata.errors.len(), 1, "{source}");
});
}
}
#[test]
fn recover_self_call_keyword() {
for source in [
r#"
local b
local a = b:do
"#,
r#"
local b
local a = b:
do end
"#,
] {
with_recover(source, |result| {
assert_eq!(result.metadata.errors.len(), 2, "{source}");
});
}
}
#[test]
fn recover_type_index_name_keyword() {
for source in [
r#"
local A
local b : A.do
"#,
r#"
local A
local b : A.do
do end
"#,
] {
with_recover(source, |result| {
assert_eq!(result.metadata.errors.len(), 1, "{source}");
});
}
}
#[test]
fn recover_expected_type_pack() {
with_recover("type Y<T..., U = T...> = (T...) -> U...", |result| {
assert_eq!(result.metadata.errors.len(), 1);
});
}
#[test]
fn recover_unexpected_type_pack() {
with_recover(
r#"
type X<T...> = { a: T..., b: number }
type Y<T> = { a: T..., b: number }
type Z<T> = { a: string | T..., b: number }
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 3);
},
);
}
#[test]
fn get_a_nice_error_when_there_is_no_comma_after_last_table_member() {
with_recover(
r#"
local t = {
first = 1
local ok = true
local good = ok == true
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].to_string(),
"Expected '}' (to close '{' at line 2), got 'local'"
);
assert_eq!(result.root.len(), 3);
let mut table_items = FirstTableItemCount::default();
result.root.visit(&mut table_items);
assert_eq!(table_items.count, Some(1));
},
);
}
#[test]
fn missing_default_type_pack_argument_after_variadic_type_parameter() {
with_recover(
r#"
type Foo<T... = > = nil
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 2);
assert_eq!(
result.metadata.errors[0].location,
loc!(pos!(1, 23), pos!(1, 25))
);
assert_eq!(result.metadata.errors[0].message, "Expected type, got '>'");
assert_eq!(
result.metadata.errors[1].location,
loc!(pos!(1, 23), pos!(1, 24))
);
assert_eq!(
result.metadata.errors[1].message,
"Expected type pack after '=', got type"
);
},
);
}
#[test]
fn invalid_escape_literals_get_reported_but_parsing_continues() {
with_recover(
r#"
local foo = "\xQQ"
print(foo)
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].location,
loc!(pos!(1, 20), pos!(1, 26))
);
assert_eq!(
result.metadata.errors[0].message,
"String literal contains malformed escape sequence"
);
assert_eq!(result.root.len(), 2);
},
);
}
#[test]
fn unfinished_string_literals_get_reported_but_parsing_continues() {
with_recover(
r#"
local foo = "hi
print(foo)
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].location,
loc!(pos!(1, 20), pos!(1, 23))
);
assert_eq!(
result.metadata.errors[0].message,
"Malformed string; did you forget to finish it?"
);
assert_eq!(result.root.len(), 2);
},
);
}
#[test]
fn unfinished_string_literal_types_get_reported_but_parsing_continues() {
with_recover(
r#"
type Foo = "hi
print(foo)
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
assert_eq!(
result.metadata.errors[0].location,
loc!(pos!(1, 19), pos!(1, 22))
);
assert_eq!(
result.metadata.errors[0].message,
"Malformed string; did you forget to finish it?"
);
assert_eq!(result.root.len(), 2);
},
);
}
#[test]
fn do_block_with_no_end() {
with_recover(
r#"
do
"#,
|result| {
assert_eq!(result.metadata.errors.len(), 1);
let [body] = statement_kinds(result.root.as_slice()).exact();
assert!(!body.as_block().expect("expected block").has_end());
},
);
}
#[test]
fn parsing_incomplete_string_interpolation_missing_curly_at_eof() {
with_recover(r#"print(`{e.x} {e.a"#, |result| {
assert_incomplete_interpolation_result(
&result,
pos!(0, 17),
"Malformed interpolated string; did you forget to add a '}'?",
loc!(pos!(0, 16), pos!(0, 17)),
);
});
}
#[test]
fn parsing_incomplete_string_interpolation_missing_backtick_at_eof() {
with_recover(r#"print(`{e.x} {e.a}"#, |result| {
assert_incomplete_interpolation_result(
&result,
pos!(0, 18),
"Malformed interpolated string; did you forget to add a '`'?",
loc!(pos!(0, 17), pos!(0, 18)),
);
});
}
#[test]
fn parsing_incomplete_string_interpolation_missing_curly_with_backtick_at_eof() {
with_recover(r#"print(`{e.x} {e.a`"#, |result| {
assert_incomplete_interpolation_result(
&result,
pos!(0, 18),
"Malformed interpolated string; did you forget to add a '}'?",
loc!(pos!(0, 17), pos!(0, 18)),
);
});
}
#[test]
fn parsing_incomplete_string_interpolation_missing_curly_broken_string() {
with_recover("print(`{e.x} {e.a\n", |result| {
assert_incomplete_interpolation_result(
&result,
pos!(0, 17),
"Malformed interpolated string; did you forget to add a '}'?",
loc!(pos!(0, 16), pos!(0, 17)),
);
});
}
#[test]
fn parsing_incomplete_string_interpolation_missing_backtick_broken_string() {
with_recover("print(`{e.x} {e.a}\n", |result| {
assert_incomplete_interpolation_result(
&result,
pos!(0, 18),
"Malformed interpolated string; did you forget to add a '`'?",
loc!(pos!(0, 17), pos!(0, 18)),
);
});
}
#[test]
fn parsing_incomplete_string_interpolation_missing_curly_with_backtick_broken_string() {
with_recover("print(`{e.x} {e.a`\n", |result| {
assert_incomplete_interpolation_result(
&result,
pos!(0, 18),
"Malformed interpolated string; did you forget to add a '}'?",
loc!(pos!(0, 17), pos!(0, 18)),
);
});
}