Skip to content

Commit

Permalink
Script execution with a custom receiver. #411
Browse files Browse the repository at this point in the history
  • Loading branch information
czyzby committed Feb 11, 2022
1 parent 5e3b018 commit 00b50c8
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 15 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ _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

- **[UPDATE]** Updated to Kotlin 1.6.10.
- **[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.
Expand Down
30 changes: 30 additions & 0 deletions script/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
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

/**
* 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."
)
Expand All @@ -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" }
Expand All @@ -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<String>) {
val script = imports.joinToString(separator = "\n") { "import $it" }
Expand Down Expand Up @@ -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 <reified T : Any?> 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 <reified T : Any?> 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)
53 changes: 50 additions & 3 deletions script/src/test/kotlin/ktx/script/KotlinScriptEngineTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,7 +117,7 @@ class KotlinScriptEngineTest {
@Test
fun `should throw exception if unable to evaluate script`() {
// Expect:
shouldThrow<GdxRuntimeException> {
shouldThrow<ScriptEngineException> {
engine.evaluate("import")
}
}
Expand All @@ -141,7 +140,7 @@ class KotlinScriptEngineTest {
val file = Gdx.files.classpath("ktx/script/broken.script")

// Expect:
shouldThrow<GdxRuntimeException> {
shouldThrow<ScriptEngineException> {
engine.evaluate(file)
}
}
Expand Down Expand Up @@ -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<ScriptEngineException> {
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:
Expand Down
1 change: 1 addition & 0 deletions script/src/test/resources/ktx/script/receiver
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
text = "test"

0 comments on commit 00b50c8

Please sign in to comment.