rhaiscript / rhai

Rhai - An embedded scripting language for Rust.
https://crates.io/crates/rhai
Apache License 2.0
3.73k stars 175 forks source link

Define variables automatically if they don't exist #695

Closed chmielot closed 1 year ago

chmielot commented 1 year ago

I have a list of user defined statements which I compile into ASTs and which I then evaluate one by one passing the same scope that is modified by the statements:

[ "x = 5", "x = y + 3", ....]

For the right hand side of the statements I have data which I can use to populate the variables with. So I am able to define y. If y is actually not defined during evaluation, it means that there's something wrong and throwing an error is the right thing to do. However, a user writing these statements doesn't know and care if a variable on the left hand side has been defined earlier. If it wasn't it should automatically be defined.

Right now I am doing the following, and I'm wondering if this is the right thing to do or if I'm missing something:

let mut engine = Engine::new();
engine.on_var(|name, index, mut context| {
    match context.scope().get_value::<Dynamic>(name) {
        Some(..) => Ok(None),
        None => {
            context.scope_mut().push_dynamic(name, Dynamic::from(false));
            return Ok(None);
        }
    }
});

And would this be thread safe?

schungx commented 1 year ago

You can do this and this would be thread safe, but you have to be very careful when modifying the scope.

If you rerun an AST, the Engine may be accessing the wrong variable... I have not tried it so I'm not sure if this would be the case, but it is usually very dangerous to try to modify the scope.

In your case you're compiling the statements one by one so this problem may not occur. Except that it may define new variables even for something on the right, when it should throw an error. For example, x = y + z should throw an error if z is undefined, but your method would define z to be false instead.

schungx commented 1 year ago

There is an example in the Book on how to implement JavaScript's var, which defines a new variable if one doesn't exist: https://rhai.rs/book/engine/custom-syntax.html#practical-example--recreating-javascripts-var-statement

If all your scripts are simple assignment statements, you can just attach var in front of every line and be done with it.

chmielot commented 1 year ago

Thank you for your comments. You are right, I can't use on_var, because then I won't be able to throw an error if there is actually an undefined variable that was not supposed to be undefined. I need a more sophisticated approach.

What's your opinion on the following strategy? I compile all of the statements once when starting the application. The statements are one-liners that are NOT ending with a semicolon. I want to keep the syntax of the statements easy and leaving out the semicolon seems to be fine. After that I loop over all compiled statements (ASTs) and merge them. The missing semicolon is not an issue, because the one-line statements result in the same AST, independent from the semicolon. So merging the ASTs works as expected.

Having a final big AST, I populate a scope with incoming data records. Some of the variables will now be defined and have values, let's call them "inputs", others will still be undefined. The only undefined variables that I want to accept are the "outputs", so the ones on the left hand side of an assignment. At the end of processing, I want to extract all outputs, rewind the scope and continue with the next data record.

To identify and extract the outputs, it seems that I need to walk the AST anyway. If I walk the AST anyway, I can define variables used on the left hand side of an assignment before running the AST and I can verify if there are any uninitialised variables on the right hand side of an assignment.

Does that sound like a viable approach?

chmielot commented 1 year ago

This is how I'm now defining "output" variables and it seems to work fine. I'm searching for an Assignment immediately followed by Variable (which means I'm looking at the left hand side) where I extract the name of the variable from. If the name doesn't exist in the scope, I'm defining it using Dynamic and the value false due to the lack of knowledge what the final type will be. Ideally, the scope API would offer to simply define a variable without assigning a value. This at least seems to be possible in Rhai script. What are your thoughts on that?

let mut scope = Scope::new();

// ast1 is "A = 1"
// ast2 is "B = 1"
let final_ast = ast1.merge(&ast2);
final_ast.walk(&mut |nodes| -> bool {
    let name = match nodes {
        [.., ASTNode::Stmt(rhai::Stmt::Assignment(..)), ASTNode::Expr(Expr::Variable(info, ..))] => {
            Some(&*info.3)
        },
        _ => None
    };
    if let Some(name) = name {
        if scope.get_value::<Dynamic>(name).is_none() {
            scope.push_dynamic(name.to_string().to_owned(), Dynamic::from(false));
        }
    }
    true
});

match engine.run_ast_with_scope(&mut scope, &final_ast);
    Ok(_) => println!("All ok"),
    Err(e) => println!("{:#?}", e),
}

Thank you in advance.

Edit: Instead of modifying the scope programmatically, I can also generate rhai statement strings like

let A;
let B;

compile this and merge the actual user defined ASTs into that.

chmielot commented 1 year ago

While experimenting a bit more, I have noticed that the scope is not updated when there are let statements within an if-statement.

#[test]
    fn test_rhai() {
        let engine = Engine::new();
        let mut scope = Scope::new();
        scope.set_value("Amount", 11000i64);
        let ast = engine.compile( "if Amount > 10000 { print(\"Condition was true\"); let Intercept=true; let Score = 500; let X=2; } let Y = 3;").unwrap();
        match engine.run_ast_with_scope(&mut scope, &ast) {
            Ok(_) => println!("All ok"),
            Err(e) => println!("{:#?}", e),
        }
        println!("{:#?}", scope);
        assert_eq!(true, false);
    }

The output is:

---- evaluation::tests::test_rhai stdout ----
Condition was true
All ok
Scope {
    values: [
        11000,
        3,
    ],
    names: [
        "Amount",
        "Y",
    ],
    aliases: [
        [],
        [],
    ],
    dummy: PhantomData,
}

Am I doing something wrong here and should I open a new issue?

schungx commented 1 year ago

While experimenting a bit more, I have noticed that the scope is not updated when there are let statements within an if-statement.

That's the correct behaviour. Variables defined inside a block scope goes away at the end of the scope.

schungx commented 1 year ago

I think the AST walk method is the cleanest.

You can create a variable in a scope simply by setting its value to Dynamic::UNIT which is the default value for variables.

Then you are certain all the outputs are defined.

Also, you can simply merge all the lines together into one large script (remember to add semicolons). Then you only need to compile once and no need to deal with merging ASTs.

chmielot commented 1 year ago

While experimenting a bit more, I have noticed that the scope is not updated when there are let statements within an if-statement.

That's the correct behaviour. Variables defined inside a block scope goes away at the end of the scope.

Of course! That makes totally sense for how Rhai works in general. And because I don't want to use the "let" keyword anyway the AST walking code will define them in the scope and it's going to work exactly as I need it to be.

Thank you very much for the support!