diff --git a/RELEASES.md b/RELEASES.md index bd4bb02d..f06c6a74 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -5,6 +5,9 @@ Rhai Release Notes Version 0.19.3 ============== +This version streamlines some of the advanced API's, and adds the `try` ... `catch` statement +to catch exceptions. + Breaking changes ---------------- @@ -17,7 +20,8 @@ New features ------------ * The plugins system is enhanced to support functions taking a `NativeCallContext` as the first parameter. -* `throw` statement can throw any value instead of just text strings. +* `throw` statement can now throw any value instead of just text strings. +* New `try` ... `catch` statement to catch exceptions. Enhancements ------------ diff --git a/doc/src/SUMMARY.md b/doc/src/SUMMARY.md index 962bdca1..3d7a3ca9 100644 --- a/doc/src/SUMMARY.md +++ b/doc/src/SUMMARY.md @@ -82,7 +82,8 @@ The Rhai Scripting Language 12. [For Loop](language/for.md) 13. [Return Values](language/return.md) 14. [Throw Exception on Error](language/throw.md) - 15. [Functions](language/functions.md) + 15. [Catch Exceptions](language/try-catch.md) + 16. [Functions](language/functions.md) 1. [Call Method as Function](language/method.md) 2. [Overloading](language/overload.md) 3. [Namespaces](language/fn-namespaces.md) @@ -90,11 +91,11 @@ The Rhai Scripting Language 5. [Currying](language/fn-curry.md) 6. [Anonymous Functions](language/fn-anon.md) 7. [Closures](language/fn-closure.md) - 16. [Print and Debug](language/print-debug.md) - 17. [Modules](language/modules/index.md) + 17. [Print and Debug](language/print-debug.md) + 18. [Modules](language/modules/index.md) 1. [Export Variables, Functions and Sub-Modules](language/modules/export.md) 2. [Import Modules](language/modules/import.md) - 18. [Eval Statement](language/eval.md) + 19. [Eval Statement](language/eval.md) 6. [Safety and Protection](safety/index.md) 1. [Checked Arithmetic](safety/checked.md) 2. [Sand-Boxing](safety/sandbox.md) diff --git a/doc/src/appendix/keywords.md b/doc/src/appendix/keywords.md index b403ac56..94419067 100644 --- a/doc/src/appendix/keywords.md +++ b/doc/src/appendix/keywords.md @@ -21,6 +21,8 @@ Keywords List | `break` | break out of loop iteration | | no | | | `return` | return value | | no | | | `throw` | throw exception | | no | | +| `try` | trap exception | | no | | +| `catch` | catch exception | | no | | | `import` | import module | [`no_module`] | no | | | `export` | export variable | [`no_module`] | no | | | `as` | alias for variable export | [`no_module`] | no | | @@ -55,8 +57,6 @@ Reserved Keywords | `case` | matching | | `public` | function/field access | | `new` | constructor | -| `try` | trap exception | -| `catch` | catch exception | | `use` | import namespace | | `with` | scope | | `module` | module | diff --git a/doc/src/language/throw.md b/doc/src/language/throw.md index 116b7b44..a11a8074 100644 --- a/doc/src/language/throw.md +++ b/doc/src/language/throw.md @@ -29,5 +29,24 @@ let result = engine.eval::(r#" } "#); -println!(result); // prints "Runtime error: 42 (line 5, position 15)" +println!("{}", result); // prints "Runtime error: 42 (line 5, position 15)" +``` + + +Catch a Thrown Exception +------------------------ + +It is possible to _catch_ an exception instead of having it abort the evaluation +of the entire script via the [`try` ... `catch`]({{rootUrl}}/language/try-catch.md) +statement common to many C-like languages. + +```rust +try +{ + throw 42; +} +catch (err) // 'err' captures the thrown exception value +{ + print(err); // prints 42 +} ``` diff --git a/doc/src/language/try-catch.md b/doc/src/language/try-catch.md new file mode 100644 index 00000000..3ea934fc --- /dev/null +++ b/doc/src/language/try-catch.md @@ -0,0 +1,104 @@ +Catch Exceptions +================ + +{{#include ../links.md}} + + +When an [exception] is thrown via a `throw` statement, evaluation of the script halts +and the [`Engine`] returns with `Err(Box)` containing the +exception value that has been thrown. + +It is possible, via the `try` ... `catch` statement, to _catch_ exceptions. + +```rust +// Catch an exception and capturing its value +try +{ + throw 42; +} +catch (err) // 'err' captures the thrown exception value +{ + print(err); // prints 42 +} + +// Catch an exception without capturing its value +try +{ + print(42/0); // deliberate divide-by-zero exception +} +catch // no catch variable - exception value is discarded +{ + print("Ouch!"); +} + +// Exception in the 'catch' block +try +{ + print(42/0); // throw divide-by-zero exception +} +catch +{ + print("You seem to be dividing by zero here..."); + + throw "die"; // a 'throw' statement inside a 'catch' block + // throws a new exception +} +``` + + +Re-Throw Exception +------------------ + +Like the `try` ... `catch` syntax in most languages, it is possible to _re-throw_ +an exception within the `catch` block simply by another `throw` statement without +a value. + + +```rust +try +{ + // Call something that will throw an exception... + do_something_bad_that_throws(); +} +catch +{ + print("Oooh! You've done something real bad!"); + + throw; // 'throw' without a value within a 'catch' block + // re-throws the original exception +} + +``` + + +Catchable Exceptions +-------------------- + +Many script-oriented exceptions can be caught via `try` ... `catch`: + +* Runtime error thrown by a `throw` statement +* Arithmetic error +* Variable not found +* [Function] not found +* [Module] not found +* Unbound [`this`] +* Data type mismatch +* [Array]/[string] indexing out-of-bounds +* Indexing with an inappropriate type +* `for` statement without an iterator +* Error in an `in` expression +* Data race detected +* Assignment to a calculated value/constant value +* Dot expression error + + +Non-Catchable Exceptions +------------------------ + +Some exceptions _cannot_ be caught: + +* Syntax error during parsing +* System error - e.g. script file not found +* Script evaluation over [limits]({{rootUrl}}/safety/index.md) +* [Stack overflow][maximum call stack depth] +* Script evaluation manually terminated diff --git a/doc/src/links.md b/doc/src/links.md index 82bc8739..3ef7d328 100644 --- a/doc/src/links.md +++ b/doc/src/links.md @@ -89,6 +89,8 @@ [function overloading]: {{rootUrl}}/rust/functions.md#function-overloading [fallible function]: {{rootUrl}}/rust/fallible.md [fallible functions]: {{rootUrl}}/rust/fallible.md +[exception]: {{rootUrl}}/language/throw.md +[exceptions]: {{rootUrl}}/language/throw.md [function pointer]: {{rootUrl}}/language/fn-ptr.md [function pointers]: {{rootUrl}}/language/fn-ptr.md [currying]: {{rootUrl}}/language/fn-curry.md diff --git a/src/engine.rs b/src/engine.rs index 54c4dc7f..118f828c 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1932,6 +1932,63 @@ impl Engine { // Break statement Stmt::Break(pos) => EvalAltResult::LoopBreak(true, *pos).into(), + // Try/Catch statement + Stmt::TryCatch(x) => { + let ((body, _), var_def, (catch_body, _)) = x.as_ref(); + + let result = self + .eval_stmt(scope, mods, state, lib, this_ptr, body, level) + .map(|_| ().into()); + + if let Err(err) = result { + match *err { + mut err @ EvalAltResult::ErrorRuntime(_, _) | mut err + if err.catchable() => + { + let value = match err { + EvalAltResult::ErrorRuntime(ref x, _) => x.clone(), + _ => { + err.set_position(Position::none()); + err.to_string().into() + } + }; + let has_var = if let Some((var_name, _)) = var_def { + let var_name = unsafe_cast_var_name_to_lifetime(var_name, &state); + scope.push(var_name, value); + state.scope_level += 1; + true + } else { + false + }; + + let mut result = self + .eval_stmt(scope, mods, state, lib, this_ptr, catch_body, level) + .map(|_| ().into()); + + if let Some(result_err) = result.as_ref().err() { + match result_err.as_ref() { + EvalAltResult::ErrorRuntime(x, pos) if x.is::<()>() => { + err.set_position(*pos); + result = Err(Box::new(err)); + } + _ => (), + } + } + + if has_var { + scope.rewind(scope.len() - 1); + state.scope_level -= 1; + } + + result + } + _ => Err(err), + } + } else { + result + } + } + // Return value Stmt::ReturnWithVal(x) if x.1.is_some() && (x.0).0 == ReturnType::Return => { let expr = x.1.as_ref().unwrap(); diff --git a/src/error.rs b/src/error.rs index 1cdfb7aa..0d680b85 100644 --- a/src/error.rs +++ b/src/error.rs @@ -104,7 +104,7 @@ pub enum ParseErrorType { /// /// Never appears under the `no_object` feature. PropertyExpected, - /// Missing a variable name after the `let`, `const` or `for` keywords. + /// Missing a variable name after the `let`, `const`, `for` or `catch` keywords. VariableExpected, /// An identifier is a reserved keyword. Reserved(String), diff --git a/src/optimize.rs b/src/optimize.rs index 0e6db3d5..5c8053e4 100644 --- a/src/optimize.rs +++ b/src/optimize.rs @@ -268,7 +268,7 @@ fn optimize_stmt(stmt: Stmt, state: &mut State, preserve_result: bool) -> Stmt { ))), // let id; stmt @ Stmt::Let(_) => stmt, - // import expr as id; + // import expr as var; #[cfg(not(feature = "no_module"))] Stmt::Import(x) => Stmt::Import(Box::new((optimize_expr(x.0, state), x.1, x.2))), // { block } @@ -389,6 +389,22 @@ fn optimize_stmt(stmt: Stmt, state: &mut State, preserve_result: bool) -> Stmt { _ => Stmt::Block(Box::new((result.into(), pos))), } } + // try { block } catch ( var ) { block } + Stmt::TryCatch(x) if (x.0).0.is_pure() => { + // If try block is pure, there will never be any exceptions + state.set_dirty(); + let pos = (x.0).0.position(); + let mut statements: StaticVec<_> = Default::default(); + statements.push(optimize_stmt((x.0).0, state, preserve_result)); + statements.push(Stmt::Noop(pos)); + Stmt::Block(Box::new((statements, pos))) + } + // try { block } catch ( var ) { block } + Stmt::TryCatch(x) => Stmt::TryCatch(Box::new(( + (optimize_stmt((x.0).0, state, false), (x.0).1), + x.1, + (optimize_stmt((x.2).0, state, false), (x.2).1), + ))), // expr; Stmt::Expr(expr) => Stmt::Expr(Box::new(optimize_expr(*expr, state))), // return expr; diff --git a/src/parser.rs b/src/parser.rs index 389bdff6..b6a57086 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -758,6 +758,14 @@ pub enum Stmt { Const(Box<((String, Position), Option, Position)>), /// { stmt; ... } Block(Box<(StaticVec, Position)>), + /// try { stmt; ... } catch ( var ) { stmt; ... } + TryCatch( + Box<( + (Stmt, Position), + Option<(String, Position)>, + (Stmt, Position), + )>, + ), /// expr Expr(Box), /// continue @@ -766,10 +774,10 @@ pub enum Stmt { Break(Position), /// return/throw ReturnWithVal(Box<((ReturnType, Position), Option, Position)>), - /// import expr as module + /// import expr as var #[cfg(not(feature = "no_module"))] Import(Box<(Expr, Option<(ImmutableString, Position)>, Position)>), - /// expr id as name, ... + /// export var as var, ... #[cfg(not(feature = "no_module"))] Export( Box<( @@ -796,13 +804,14 @@ impl Stmt { Stmt::Noop(pos) | Stmt::Continue(pos) | Stmt::Break(pos) => *pos, Stmt::Let(x) => (x.0).1, Stmt::Const(x) => (x.0).1, - Stmt::ReturnWithVal(x) => (x.0).1, Stmt::Block(x) => x.1, Stmt::IfThenElse(x) => x.3, Stmt::Expr(x) => x.position(), Stmt::While(x) => x.2, Stmt::Loop(x) => x.1, Stmt::For(x) => x.3, + Stmt::ReturnWithVal(x) => (x.0).1, + Stmt::TryCatch(x) => (x.0).1, #[cfg(not(feature = "no_module"))] Stmt::Import(x) => x.2, @@ -820,7 +829,6 @@ impl Stmt { Stmt::Noop(pos) | Stmt::Continue(pos) | Stmt::Break(pos) => *pos = new_pos, Stmt::Let(x) => (x.0).1 = new_pos, Stmt::Const(x) => (x.0).1 = new_pos, - Stmt::ReturnWithVal(x) => (x.0).1 = new_pos, Stmt::Block(x) => x.1 = new_pos, Stmt::IfThenElse(x) => x.3 = new_pos, Stmt::Expr(x) => { @@ -829,6 +837,8 @@ impl Stmt { Stmt::While(x) => x.2 = new_pos, Stmt::Loop(x) => x.1 = new_pos, Stmt::For(x) => x.3 = new_pos, + Stmt::ReturnWithVal(x) => (x.0).1 = new_pos, + Stmt::TryCatch(x) => (x.0).1 = new_pos, #[cfg(not(feature = "no_module"))] Stmt::Import(x) => x.2 = new_pos, @@ -849,7 +859,8 @@ impl Stmt { | Stmt::While(_) | Stmt::Loop(_) | Stmt::For(_) - | Stmt::Block(_) => true, + | Stmt::Block(_) + | Stmt::TryCatch(_) => true, // A No-op requires a semicolon in order to know it is an empty statement! Stmt::Noop(_) => false, @@ -884,6 +895,7 @@ impl Stmt { Stmt::Let(_) | Stmt::Const(_) => false, Stmt::Block(x) => x.0.iter().all(Stmt::is_pure), Stmt::Continue(_) | Stmt::Break(_) | Stmt::ReturnWithVal(_) => false, + Stmt::TryCatch(x) => (x.0).0.is_pure() && (x.2).0.is_pure(), #[cfg(not(feature = "no_module"))] Stmt::Import(_) => false, @@ -1358,13 +1370,12 @@ fn eat_token(input: &mut TokenStream, token: Token) -> Position { } /// Match a particular token, consuming it if matched. -fn match_token(input: &mut TokenStream, token: Token) -> Result { - let (t, _) = input.peek().unwrap(); +fn match_token(input: &mut TokenStream, token: Token) -> (bool, Position) { + let (t, pos) = input.peek().unwrap(); if *t == token { - eat_token(input, token); - Ok(true) + (true, eat_token(input, token)) } else { - Ok(false) + (false, *pos) } } @@ -1378,7 +1389,7 @@ fn parse_paren_expr( #[cfg(not(feature = "unchecked"))] settings.ensure_level_within_max_limit(state.max_expr_depth)?; - if match_token(input, Token::RightParen)? { + if match_token(input, Token::RightParen).0 { return Ok(Expr::Unit(settings.pos)); } @@ -1989,7 +2000,7 @@ fn parse_primary( // Qualified function call with ! #[cfg(not(feature = "no_closure"))] (Expr::Variable(x), Token::Bang) if x.1.is_some() => { - return Err(if !match_token(input, Token::LeftParen)? { + return Err(if !match_token(input, Token::LeftParen).0 { LexError::UnexpectedInput(Token::Bang.syntax().to_string()).into_err(token_pos) } else { PERR::BadInput("'!' cannot be used to call module functions".to_string()) @@ -1999,12 +2010,13 @@ fn parse_primary( // Function call with ! #[cfg(not(feature = "no_closure"))] (Expr::Variable(x), Token::Bang) => { - if !match_token(input, Token::LeftParen)? { + let (matched, pos) = match_token(input, Token::LeftParen); + if !matched { return Err(PERR::MissingToken( Token::LeftParen.syntax().into(), "to start arguments list of function call".into(), ) - .into_err(input.peek().unwrap().1)); + .into_err(pos)); } let ((name, pos), modules, _, _) = *x; @@ -2813,7 +2825,7 @@ fn parse_if( let if_body = parse_block(input, state, lib, settings.level_up())?; // if guard { if_body } else ... - let else_body = if match_token(input, Token::Else).unwrap_or(false) { + let else_body = if match_token(input, Token::Else).0 { Some(if let (Token::If, _) = input.peek().unwrap() { // if guard { if_body } else if ... parse_if(input, state, lib, settings.level_up())? @@ -2957,7 +2969,7 @@ fn parse_let( }; // let name = ... - let init_value = if match_token(input, Token::Equals)? { + let init_value = if match_token(input, Token::Equals).0 { // let name = expr Some(parse_expr(input, state, lib, settings.level_up())?) } else { @@ -2997,7 +3009,7 @@ fn parse_import( let expr = parse_expr(input, state, lib, settings.level_up())?; // import expr as ... - if !match_token(input, Token::As)? { + if !match_token(input, Token::As).0 { return Ok(Stmt::Import(Box::new((expr, None, token_pos)))); } @@ -3046,7 +3058,7 @@ fn parse_export( (_, pos) => return Err(PERR::VariableExpected.into_err(pos)), }; - let rename = if match_token(input, Token::As)? { + let rename = if match_token(input, Token::As).0 { match input.next().unwrap() { (Token::Identifier(s), pos) => Some((s.clone(), pos)), (Token::Reserved(s), pos) if is_valid_identifier(s.chars()) => { @@ -3121,7 +3133,7 @@ fn parse_block( #[cfg(not(feature = "no_module"))] let prev_mods_len = state.modules.len(); - while !match_token(input, Token::RightBrace)? { + while !match_token(input, Token::RightBrace).0 { // Parse statements inside the block settings.is_global = false; @@ -3320,6 +3332,8 @@ fn parse_stmt( } } + Token::Try => parse_try_catch(input, state, lib, settings.level_up()).map(Some), + Token::Let => parse_let(input, state, lib, Normal, settings.level_up()).map(Some), Token::Const => parse_let(input, state, lib, Constant, settings.level_up()).map(Some), @@ -3336,6 +3350,65 @@ fn parse_stmt( } } +/// Parse a try/catch statement. +fn parse_try_catch( + input: &mut TokenStream, + state: &mut ParseState, + lib: &mut FunctionsLib, + mut settings: ParseSettings, +) -> Result { + // try ... + let token_pos = eat_token(input, Token::Try); + settings.pos = token_pos; + + #[cfg(not(feature = "unchecked"))] + settings.ensure_level_within_max_limit(state.max_expr_depth)?; + + // try { body } + let body = parse_block(input, state, lib, settings.level_up())?; + + // try { body } catch + let (matched, catch_pos) = match_token(input, Token::Catch); + + if !matched { + return Err( + PERR::MissingToken(Token::Catch.into(), "for the 'try' statement".into()) + .into_err(catch_pos), + ); + } + + // try { body } catch ( + let var_def = if match_token(input, Token::LeftParen).0 { + let id = match input.next().unwrap() { + (Token::Identifier(s), pos) => (s, pos), + (_, pos) => return Err(PERR::VariableExpected.into_err(pos)), + }; + + let (matched, pos) = match_token(input, Token::RightParen); + + if !matched { + return Err(PERR::MissingToken( + Token::RightParen.into(), + "to enclose the catch variable".into(), + ) + .into_err(pos)); + } + + Some(id) + } else { + None + }; + + // try { body } catch ( var ) { catch_block } + let catch_body = parse_block(input, state, lib, settings.level_up())?; + + Ok(Stmt::TryCatch(Box::new(( + (body, token_pos), + var_def, + (catch_body, catch_pos), + )))) +} + /// Parse a function definition. #[cfg(not(feature = "no_function"))] fn parse_fn( @@ -3364,7 +3437,7 @@ fn parse_fn( let mut params = Vec::new(); - if !match_token(input, Token::RightParen)? { + if !match_token(input, Token::RightParen).0 { let sep_err = format!("to separate the parameters of function '{}'", name); loop { @@ -3514,7 +3587,7 @@ fn parse_anon_fn( let mut params = Vec::new(); if input.next().unwrap().0 != Token::Or { - if !match_token(input, Token::Pipe)? { + if !match_token(input, Token::Pipe).0 { loop { match input.next().unwrap() { (Token::Pipe, _) => break, diff --git a/src/token.rs b/src/token.rs index 55dc2d38..fef81ac8 100644 --- a/src/token.rs +++ b/src/token.rs @@ -282,6 +282,10 @@ pub enum Token { Return, /// `throw` Throw, + /// `try` + Try, + /// `catch` + Catch, /// `+=` PlusAssign, /// `-=` @@ -397,6 +401,8 @@ impl Token { Break => "break", Return => "return", Throw => "throw", + Try => "try", + Catch => "catch", PlusAssign => "+=", MinusAssign => "-=", MultiplyAssign => "*=", @@ -479,6 +485,8 @@ impl Token { "break" => Break, "return" => Return, "throw" => Throw, + "try" => Try, + "catch" => Catch, "+=" => PlusAssign, "-=" => MinusAssign, "*=" => MultiplyAssign, @@ -516,9 +524,9 @@ impl Token { "===" | "!==" | "->" | "<-" | "=>" | ":=" | "::<" | "(*" | "*)" | "#" | "public" | "new" | "use" | "module" | "package" | "var" | "static" | "shared" | "with" - | "do" | "each" | "then" | "goto" | "exit" | "switch" | "match" | "case" | "try" - | "catch" | "default" | "void" | "null" | "nil" | "spawn" | "go" | "sync" | "async" - | "await" | "yield" => Reserved(syntax.into()), + | "do" | "each" | "then" | "goto" | "exit" | "switch" | "match" | "case" + | "default" | "void" | "null" | "nil" | "spawn" | "go" | "sync" | "async" | "await" + | "yield" => Reserved(syntax.into()), KEYWORD_PRINT | KEYWORD_DEBUG | KEYWORD_TYPE_OF | KEYWORD_EVAL | KEYWORD_FN_PTR | KEYWORD_FN_PTR_CALL | KEYWORD_FN_PTR_CURRY | KEYWORD_IS_DEF_VAR diff --git a/tests/throw.rs b/tests/throw.rs index 2d7ed7e0..146a40bc 100644 --- a/tests/throw.rs +++ b/tests/throw.rs @@ -1,4 +1,4 @@ -use rhai::{Engine, EvalAltResult}; +use rhai::{Engine, EvalAltResult, INT}; #[test] fn test_throw() { @@ -14,3 +14,27 @@ fn test_throw() { EvalAltResult::ErrorRuntime(s, _) if s.is::<()>() )); } + +#[test] +fn test_try_catch() -> Result<(), Box> { + let engine = Engine::new(); + + assert_eq!( + engine.eval::("try { throw 42; } catch (x) { return x; }")?, + 42 + ); + + assert_eq!( + engine.eval::("try { throw 42; } catch { return 123; }")?, + 123 + ); + + assert!(matches!( + *engine + .eval::<()>("try { 42/0; } catch { throw; }") + .expect_err("expects error"), + EvalAltResult::ErrorArithmetic(_, _) + )); + + Ok(()) +}