diff --git a/CHANGELOG.md b/CHANGELOG.md index f25d9d65..c0f92b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ _See also: [the official libGDX changelog](https://github.com/libgdx/libgdx/blob - `Engine.onEntityAdded` and `Engine.onEntityRemoved` extension methods that create entity listeners from lambdas. - Wrappers for `Engine.onEntityAdded` and `Engine.onEntityRemoved` for `IteratingSystem`, `IntervalIteratingSystem` and `SortedIteratingSystem` that use system's `Family` and `Engine` automatically. +- **[FEATURE]** (`ktx-script`) Added `KotlinScriptEngine.evaluateOn` methods that can execute scripts with a custom receiver. +- **[CHANGE]** (`ktx-script`) Generic libGDX and Java exceptions replaced with a custom `ScriptEngineException`. #### 1.10.0-rc1 @@ -15,8 +17,8 @@ _See also: [the official libGDX changelog](https://github.com/libgdx/libgdx/blob - **[UPDATE]** Updated to Kotlin Coroutines 1.6.0. - **[MISC]** Links to the libGDX wiki were updated. - **[MISC]** Stable **KTX** releases are now marked with the `-rc` suffix. +- **[FEATURE]** (`ktx-actors`) Added `Tree.onSelectionChange` extension method that attaches a `ChangeListener` to a `Tree`. - **[CHANGE]** (`ktx-scene2d`) The generic `Node` type of `KTreeWidget` was changed to `KNode<*>`. -- **[FEATURE]** Added `Tree.onSelectionChange` extension method that attaches a `ChangeListener` to a `Tree`. - **[FEATURE]** (`ktx-script`) Added a new module with `KotlinScriptEngine` evaluating Kotlin scripts in runtime. - `evaluate(String)`: compiles and executes a script passed as a string. - `evaluate(FileHandle)`: compiles and executes a script from the selected file. diff --git a/script/README.md b/script/README.md index fc708bcd..53891fb4 100644 --- a/script/README.md +++ b/script/README.md @@ -21,6 +21,8 @@ Returns the last expression from the script as `Any?`. Returns the last expression from the script as `T`. Throws `ClassCastException` if the result does not match `T` type. - `evaluateAs(FileHandle)`: compiles and executes a script from the selected file. Returns the last expression from the script as `T`. Throws `ClassCastException` if the result does not match `T` type. +- `evaluateOn(Any, String)`: compiles and executes a script passed as string with a custom receiver available as `this`. +- `evaluateOn(Any, FileHandle)`: compiles and executes a script from a file with a custom receiver available as `this`. - `set(String, Any)`: adds a variable to the script execution context. The variable will be available in the scripts under the given name. @@ -57,6 +59,8 @@ have to be returned as script results, or otherwise passed outside the script co the engine instance, IDE might not be able to pick them up without additional setup. * **Scripts might be unable to infer the generic types.** Avoid passing generic objects as variables to the scripts. * **Targets Java 8.** Using newer language features might result in exceptions. +* **Scripts with receivers cannot contain any import statements.** This only affects the `KotlinScriptEngine.evaluateOn` +methods. #### Advantages over using `ScriptEngine` directly @@ -185,6 +189,32 @@ fun executeScript(engine: KotlinScriptEngine) { } ``` +Executing a script with a receiver (`this`): + +```kotlin +import ktx.script.KotlinScriptEngine + +data class Data(var text: String = "") + +fun executeScript(engine: KotlinScriptEngine) { + val receiver = Data() + + engine.evaluateOn( + receiver, + """ + // The receiver is now available as `this` in the script: + text = "Hello from script!" + println(this.text) + + // Note that scripts with a receiver cannot have any imports. + """ + ) + // Property modified in the script will persist + // outside the script scope: + println(receiver.text) +} +``` + ### Alternatives - Using the JSR-223 `ScriptEngine` directly. diff --git a/script/src/main/kotlin/ktx/script/KotlinScriptEngine.kt b/script/src/main/kotlin/ktx/script/script.kt similarity index 63% rename from script/src/main/kotlin/ktx/script/KotlinScriptEngine.kt rename to script/src/main/kotlin/ktx/script/script.kt index 1476a2a0..273b8616 100644 --- a/script/src/main/kotlin/ktx/script/KotlinScriptEngine.kt +++ b/script/src/main/kotlin/ktx/script/script.kt @@ -1,8 +1,8 @@ package ktx.script import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.math.MathUtils import com.badlogic.gdx.utils.GdxRuntimeException -import java.lang.IllegalStateException import javax.script.ScriptContext import javax.script.ScriptEngine import javax.script.ScriptEngineManager @@ -10,12 +10,12 @@ import javax.script.ScriptEngineManager /** * Executes Kotlin scripts in runtime. * - * Wraps around JSR-223 [ScriptEngine] for the Kotlin language. + * Wraps around the official implementation of the JSR-223 [ScriptEngine] for the Kotlin language. */ class KotlinScriptEngine { /** Direct reference to the wrapped JSR-223 [ScriptEngine]. */ val engine: ScriptEngine = ScriptEngineManager().getEngineByExtension("kts") - ?: throw IllegalStateException( + ?: throw ScriptEngineException( "Unable to find engine for extension: kts. " + "Make sure to include the org.jetbrains.kotlin:kotlin-scripting-jsr223 dependency." ) @@ -41,7 +41,7 @@ class KotlinScriptEngine { * The [imports] will be available in future scripts. * * To assign an alias to a specific import, use Kotlin `as` operator after the qualified name. - * For example: `engine.importAll("com.badlogic.gdx.utils.Array as GdxArray") + * For example: `engine.importAll("com.badlogic.gdx.utils.Array as GdxArray")` */ fun importAll(vararg imports: String) { val script = imports.joinToString(separator = "\n") { "import $it" } @@ -53,7 +53,7 @@ class KotlinScriptEngine { * The [imports] will be available in future scripts. * * To assign an alias to a specific import, use Kotlin `as` operator after the qualified name. - * For example: `engine.importAll("com.badlogic.gdx.utils.Array as GdxArray") + * For example: `engine.importAll("com.badlogic.gdx.utils.Array as GdxArray")` */ fun importAll(imports: Iterable) { val script = imports.joinToString(separator = "\n") { "import $it" } @@ -93,33 +93,81 @@ class KotlinScriptEngine { /** * Executes the selected [script]. Returns the last script's expression as the result. - * If unable to execute the script, [GdxRuntimeException] will be thrown. + * If unable to execute the script, [ScriptEngineException] will be thrown. */ fun evaluate(script: String): Any? = try { engine.eval(script) } catch (exception: Throwable) { - throw GdxRuntimeException("Unable to execute Kotlin script:\n$script", exception) + throw ScriptEngineException("Unable to execute Kotlin script:\n$script", exception) } /** * Executes the selected [scriptFile]. Returns the last script's expression as the result. - * If unable to execute the script, [GdxRuntimeException] will be thrown. + * If unable to execute the script, [ScriptEngineException] will be thrown. */ fun evaluate(scriptFile: FileHandle): Any? = try { engine.eval(scriptFile.reader()) } catch (exception: Throwable) { - throw GdxRuntimeException("Unable to execute Kotlin script from file: $scriptFile", exception) + throw ScriptEngineException("Unable to execute Kotlin script from file: $scriptFile", exception) } + /** + * Executes the selected [script] on the [receiver] object. The [receiver] will be available as `this` + * throughout the script, as well as under the value of [receiverVariableName]. + * If no variable name is given, it will be chosen randomly. + * + * Note that the script cannot contain any import statement, or it will fail with a [ScriptEngineException]. + * Use [import] or [importAll] instead. + */ + fun evaluateOn( + receiver: Any, + script: String, + receiverVariableName: String = getRandomReceiverName() + ) { + try { + this[receiverVariableName] = receiver + evaluate("$receiverVariableName.apply{$script}") + } finally { + remove(receiverVariableName) + } + } + + /** + * Executes the selected [scriptFile] on the [receiver] object. The [receiver] will be available as `this` + * throughout the script, as well as under the value of [receiverVariableName]. + * If no variable name is given, it will be chosen randomly. + * + * Note that the script cannot contain any import statement, or it will fail with a [ScriptEngineException]. + * Use [import] or [importAll] instead. + */ + fun evaluateOn( + receiver: Any, + scriptFile: FileHandle, + receiverVariableName: String = getRandomReceiverName() + ) { + try { + evaluateOn(receiver, scriptFile.readString(), receiverVariableName) + } catch (exception: Throwable) { + throw ScriptEngineException("Unable to execute script with a receiver from file: $scriptFile", exception) + } + } + + private fun getRandomReceiverName(): String = "receiver" + MathUtils.random(0, Int.MAX_VALUE - 1) + /** * Executes the selected [script] and returns an instance of [T]. If the script is not an instance of [T], - * [ClassCastException] will be thrown. If unable to execute the script, [GdxRuntimeException] will be thrown. + * [ClassCastException] will be thrown. If unable to execute the script, [ScriptEngineException] will be thrown. */ inline fun evaluateAs(script: String): T = evaluate(script) as T /** * Executes the selected [scriptFile] and returns an instance of [T]. If the script is not an instance of [T], - * [ClassCastException] will be thrown. If unable to execute the script, [GdxRuntimeException] will be thrown. + * [ClassCastException] will be thrown. If unable to execute the script, [ScriptEngineException] will be thrown. */ inline fun evaluateAs(scriptFile: FileHandle): T = evaluate(scriptFile) as T } + +/** + * Thrown when unable to execute a script or configure the scripting engine. + */ +class ScriptEngineException(message: String, cause: Throwable? = null) : GdxRuntimeException(message, cause) diff --git a/script/src/test/kotlin/ktx/script/KotlinScriptEngineTest.kt b/script/src/test/kotlin/ktx/script/KotlinScriptEngineTest.kt index d4d17609..afc465b2 100644 --- a/script/src/test/kotlin/ktx/script/KotlinScriptEngineTest.kt +++ b/script/src/test/kotlin/ktx/script/KotlinScriptEngineTest.kt @@ -2,7 +2,6 @@ package ktx.script import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Files -import com.badlogic.gdx.utils.GdxRuntimeException import io.kotlintest.matchers.shouldThrow import org.junit.AfterClass import org.junit.Assert.assertEquals @@ -118,7 +117,7 @@ class KotlinScriptEngineTest { @Test fun `should throw exception if unable to evaluate script`() { // Expect: - shouldThrow { + shouldThrow { engine.evaluate("import") } } @@ -141,7 +140,7 @@ class KotlinScriptEngineTest { val file = Gdx.files.classpath("ktx/script/broken.script") // Expect: - shouldThrow { + shouldThrow { engine.evaluate(file) } } @@ -209,6 +208,54 @@ class KotlinScriptEngineTest { assertEquals(GdxArray.with(Data("test"), Data("test"), Data("test")), result) } + @Test + fun `should execute script with a receiver`() { + // Given: + val receiver = Data(text = "") + + // When: + engine.evaluateOn( + receiver, + """ + text = "test" + """.trimIndent() + ) + + // Then: + assertEquals("test", receiver.text) + } + + @Test + fun `should fail to execute script with a receiver if it contains an import`() { + // Given: + val receiver = Data(text = "") + + // Expect: + shouldThrow { + engine.evaluateOn( + receiver, + """ + import com.badlogic.gdx.Gdx + + text = "test" + """.trimIndent() + ) + } + } + + @Test + fun `should execute script from a file with a receiver`() { + // Given: + val receiver = Data(text = "") + val file = Gdx.files.classpath("ktx/script/receiver") + + // When: + engine.evaluateOn(receiver, file) + + // Then: + assertEquals("test", receiver.text) + } + @Test fun `should assign context variable`() { // Given: diff --git a/script/src/test/resources/ktx/script/receiver b/script/src/test/resources/ktx/script/receiver new file mode 100644 index 00000000..715659b7 --- /dev/null +++ b/script/src/test/resources/ktx/script/receiver @@ -0,0 +1 @@ +text = "test"