better checks for mutability, sipport for inplace mutation with assign()

This commit is contained in:
Sergey Chernov 2025-05-29 01:35:02 +04:00
parent ff03f3066d
commit ca93d73b9c
6 changed files with 149 additions and 82 deletions

View File

@ -76,13 +76,16 @@ Assignemnt is an expression that changes its lvalue and return assigned value:
As the assignment itself is an expression, you can use it in strange ways. Just remember As the assignment itself is an expression, you can use it in strange ways. Just remember
to use parentheses as assignment operation insofar is left-associated and will not to use parentheses as assignment operation insofar is left-associated and will not
allow chained assignments (we might fix it later) allow chained assignments (we might fix it later). Use parentheses insofar:
var x = 0 var x = 0
var y = 0 var y = 0
x = (y = 5) x = (y = 5)
x + y assert(x==5)
>>> 10 assert(y==5)
>>> void
Note that assignment operator returns rvalue, it can't be assigned.
## Modifying arithmetics ## Modifying arithmetics
@ -98,6 +101,11 @@ There is a set of assigning operations: `+=`, `-=`, `*=`, `/=` and even `%=`.
Notice the parentheses here: the assignment has low priority! Notice the parentheses here: the assignment has low priority!
These operators return rvalue, unmodifiable.
## Assignemnt return r-value!
## Math ## Math
It is rather simple, like everywhere else: It is rather simple, like everywhere else:
@ -110,7 +118,7 @@ See [math](math.md) for more on it. Notice using Greek as identifier, all langua
Logical operation could be used the same Logical operation could be used the same
val x = 10 var x = 10
++x >= 11 ++x >= 11
>>> true >>> true
@ -154,11 +162,18 @@ Correct pattern is:
// now is OK: // now is OK:
foo + bar foo + bar
This is though a rare case when you need uninitialized variables, most often you can use conditional operatorss This is though a rare case when you need uninitialized variables, most often you can use conditional operators
and even loops to assign results (see below). and even loops to assign results (see below).
# Constants # Constants
Almost the same, using `val`:
val foo = 1
foo += 1 // this will throw exception
# Constants
Same as in kotlin: Same as in kotlin:
val HalfPi = π / 2 val HalfPi = π / 2

View File

@ -86,7 +86,7 @@ class Compiler(
private fun parseExpression(tokens: CompilerContext): Statement? { private fun parseExpression(tokens: CompilerContext): Statement? {
val pos = tokens.currentPos() val pos = tokens.currentPos()
return parseExpressionLevel(tokens)?.let { a -> statement(pos) { a.getter(it) } } return parseExpressionLevel(tokens)?.let { a -> statement(pos) { a.getter(it).value } }
} }
private fun parseExpressionLevel(tokens: CompilerContext, level: Int = 0): Accessor? { private fun parseExpressionLevel(tokens: CompilerContext, level: Int = 0): Accessor? {
@ -185,9 +185,9 @@ class Compiler(
} }
Token.Type.NOT -> { Token.Type.NOT -> {
if( operand != null ) throw ScriptError(t.pos, "unexpected operator not '!'") if (operand != null) throw ScriptError(t.pos, "unexpected operator not '!'")
val op = parseTerm3(cc) ?: throw ScriptError(t.pos, "Expecting expression") val op = parseTerm3(cc) ?: throw ScriptError(t.pos, "Expecting expression")
operand = Accessor { op.getter(it).logicalNot(it) } operand = Accessor { op.getter(it).value.logicalNot(it).asReadonly }
} }
Token.Type.DOT -> { Token.Type.DOT -> {
@ -202,18 +202,20 @@ class Compiler(
isCall = true isCall = true
operand = Accessor { context -> operand = Accessor { context ->
context.pos = next.pos context.pos = next.pos
val v = left.getter(context) val v = left.getter(context).value
v.callInstanceMethod( WithAccess(
context, v.callInstanceMethod(
next.value, context,
args.toArguments() next.value,
args.toArguments()
), isMutable = false
) )
} }
} }
} }
if (!isCall) { if (!isCall) {
operand = Accessor { context -> operand = Accessor { context ->
left.getter(context).readField(context, next.value) left.getter(context).value.readField(context, next.value)
} }
} }
} ?: throw ScriptError(t.pos, "Expecting expression before dot") } ?: throw ScriptError(t.pos, "Expecting expression before dot")
@ -234,7 +236,7 @@ class Compiler(
// Expression in parentheses // Expression in parentheses
val statement = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting expression") val statement = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting expression")
operand = Accessor { operand = Accessor {
statement.execute(it) statement.execute(it).asReadonly
} }
cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true) cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true)
cc.skipTokenOfType(Token.Type.RPAREN, "missing ')'") cc.skipTokenOfType(Token.Type.RPAREN, "missing ')'")
@ -248,7 +250,7 @@ class Compiler(
if (operand != null) throw ScriptError(t.pos, "unexpected keyword") if (operand != null) throw ScriptError(t.pos, "unexpected keyword")
cc.previous() cc.previous()
val s = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting valid statement") val s = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting valid statement")
operand = Accessor { s.execute(it) } operand = Accessor { s.execute(it).asReadonly }
} }
"else", "break", "continue" -> { "else", "break", "continue" -> {
@ -263,10 +265,10 @@ class Compiler(
// is RW: // is RW:
operand = Accessor({ operand = Accessor({
it.pos = t.pos it.pos = t.pos
left.getter(it).readField(it, t.value) left.getter(it).value.readField(it, t.value)
}) { cxt, newValue -> }) { cxt, newValue ->
cxt.pos = t.pos cxt.pos = t.pos
left.getter(cxt).writeField(cxt, t.value, newValue) left.getter(cxt).value.writeField(cxt, t.value, newValue)
} }
} ?: run { } ?: run {
// variable to read or like // variable to read or like
@ -284,13 +286,20 @@ class Compiler(
operand?.let { left -> operand?.let { left ->
// post increment // post increment
left.setter(startPos) left.setter(startPos)
operand = Accessor({ ctx -> operand = Accessor({ cxt ->
left.getter(ctx).getAndIncrement(ctx) val x = left.getter(cxt)
if (x.isMutable)
x.value.getAndIncrement(cxt).asReadonly
else cxt.raiseError("Cannot increment immutable value")
}) })
} ?: run { } ?: run {
// no lvalue means pre-increment, expression to increment follows // no lvalue means pre-increment, expression to increment follows
val next = parseAccessor(cc) ?: throw ScriptError(t.pos, "Expecting expression") val next = parseAccessor(cc) ?: throw ScriptError(t.pos, "Expecting expression")
operand = Accessor({ ctx -> next.getter(ctx).incrementAndGet(ctx) }) operand = Accessor({ ctx ->
next.getter(ctx).also {
if (!it.isMutable) ctx.raiseError("Cannot increment immutable value")
}.value.incrementAndGet(ctx).asReadonly
})
} }
} }
@ -300,12 +309,18 @@ class Compiler(
// post decrement // post decrement
left.setter(startPos) left.setter(startPos)
operand = Accessor { ctx -> operand = Accessor { ctx ->
left.getter(ctx).getAndDecrement(ctx) left.getter(ctx).also {
if (!it.isMutable) ctx.raiseError("Cannot decrement immutable value")
}.value.getAndDecrement(ctx).asReadonly
} }
} ?: run { } ?: run {
// no lvalue means pre-decrement, expression to decrement follows // no lvalue means pre-decrement, expression to decrement follows
val next = parseAccessor(cc) ?: throw ScriptError(t.pos, "Expecting expression") val next = parseAccessor(cc) ?: throw ScriptError(t.pos, "Expecting expression")
operand = Accessor { ctx -> next.getter(ctx).decrementAndGet(ctx) } operand = Accessor { ctx ->
next.getter(ctx).also {
if (!it.isMutable) ctx.raiseError("Cannot decrement immutable value")
}.value.decrementAndGet(ctx).asReadonly
}
} }
} }
@ -327,7 +342,7 @@ class Compiler(
if (t.type != Token.Type.ID) throw ScriptError(t.pos, "Expecting ID after ::") if (t.type != Token.Type.ID) throw ScriptError(t.pos, "Expecting ID after ::")
return when (t.value) { return when (t.value) {
"class" -> Accessor { "class" -> Accessor {
operand.getter(it).objClass operand.getter(it).value.objClass.asReadonly
} }
else -> throw ScriptError(t.pos, "Unknown scope operation: ${t.value}") else -> throw ScriptError(t.pos, "Unknown scope operation: ${t.value}")
@ -357,13 +372,13 @@ class Compiler(
return Accessor { context -> return Accessor { context ->
val v = left.getter(context) val v = left.getter(context)
v.callOn(context.copy( v.value.callOn(context.copy(
context.pos, context.pos,
Arguments( Arguments(
args.map { Arguments.Info((it.value as Statement).execute(context), it.pos) } args.map { Arguments.Info((it.value as Statement).execute(context), it.pos) }
), ),
) )
) ).asReadonly
} }
} }
@ -374,31 +389,31 @@ class Compiler(
Token.Type.INT, Token.Type.REAL, Token.Type.HEX -> { Token.Type.INT, Token.Type.REAL, Token.Type.HEX -> {
cc.previous() cc.previous()
val n = parseNumber(true, cc) val n = parseNumber(true, cc)
Accessor({ n }) Accessor{ n.asReadonly }
} }
Token.Type.STRING -> Accessor({ ObjString(t.value) }) Token.Type.STRING -> Accessor { ObjString(t.value).asReadonly }
Token.Type.PLUS -> { Token.Type.PLUS -> {
val n = parseNumber(true, cc) val n = parseNumber(true, cc)
Accessor { n } Accessor { n.asReadonly }
} }
Token.Type.MINUS -> { Token.Type.MINUS -> {
val n = parseNumber(false, cc) val n = parseNumber(false, cc)
Accessor { n } Accessor { n.asReadonly }
} }
Token.Type.ID -> { Token.Type.ID -> {
when (t.value) { when (t.value) {
"void" -> Accessor { ObjVoid } "void" -> Accessor { ObjVoid.asReadonly }
"null" -> Accessor { ObjNull } "null" -> Accessor { ObjNull.asReadonly }
"true" -> Accessor { ObjBool(true) } "true" -> Accessor { ObjBool(true).asReadonly }
"false" -> Accessor { ObjBool(false) } "false" -> Accessor { ObjBool(false).asReadonly }
else -> { else -> {
Accessor({ Accessor({
it.pos = t.pos it.pos = t.pos
it.get(t.value)?.value it.get(t.value)?.asAccess
?: it.raiseError("symbol not defined: '${t.value}'") ?: it.raiseError("symbol not defined: '${t.value}'")
}) { ctx, newValue -> }) { ctx, newValue ->
ctx.get(t.value)?.let { stored -> ctx.get(t.value)?.let { stored ->
@ -719,7 +734,7 @@ class Compiler(
data class Operator( data class Operator(
val tokenType: Token.Type, val tokenType: Token.Type,
val priority: Int, val arity: Int=2, val priority: Int, val arity: Int = 2,
val generate: (Pos, Accessor, Accessor) -> Accessor val generate: (Pos, Accessor, Accessor) -> Accessor
) { ) {
// fun isLeftAssociative() = tokenType != Token.Type.OR && tokenType != Token.Type.AND // fun isLeftAssociative() = tokenType != Token.Type.OR && tokenType != Token.Type.AND
@ -727,7 +742,7 @@ class Compiler(
companion object { companion object {
fun simple(tokenType: Token.Type, priority: Int, f: suspend (Context, Obj, Obj) -> Obj): Operator = fun simple(tokenType: Token.Type, priority: Int, f: suspend (Context, Obj, Obj) -> Obj): Operator =
Operator(tokenType, priority, 2, { _: Pos, a: Accessor, b: Accessor -> Operator(tokenType, priority, 2, { _: Pos, a: Accessor, b: Accessor ->
Accessor { f(it, a.getter(it), b.getter(it)) } Accessor { f(it, a.getter(it).value, b.getter(it).value).asReadonly }
}) })
} }
@ -740,73 +755,74 @@ class Compiler(
// assignments // assignments
Operator(Token.Type.ASSIGN, lastPrty) { pos, a, b -> Operator(Token.Type.ASSIGN, lastPrty) { pos, a, b ->
Accessor { Accessor {
val value = b.getter(it) val value = b.getter(it).value
a.setter(pos)(it, value) val access = a.getter(it)
value if (!access.isMutable) throw ScriptError(pos, "cannot assign to immutable variable")
if (access.value.assign(it, value) == null)
a.setter(pos)(it, value)
value.asReadonly
} }
}, },
Operator(Token.Type.PLUSASSIGN, lastPrty) { pos, a, b -> Operator(Token.Type.PLUSASSIGN, lastPrty) { pos, a, b ->
Accessor { Accessor {
val x = a.getter(it) val x = a.getter(it).value
val y = b.getter(it) val y = b.getter(it).value
x.plusAssign(it, y) ?: run { (x.plusAssign(it, y) ?: run {
val result = x.plus(it, y) val result = x.plus(it, y)
a.setter(pos)(it, result) a.setter(pos)(it, result)
result result
} }).asReadonly
} }
}, },
Operator(Token.Type.MINUSASSIGN, lastPrty) { pos, a, b -> Operator(Token.Type.MINUSASSIGN, lastPrty) { pos, a, b ->
Accessor { Accessor {
val x = a.getter(it) val x = a.getter(it).value
val y = b.getter(it) val y = b.getter(it).value
x.minusAssign(it, y) ?: run { (x.minusAssign(it, y) ?: run {
val result = x.minus(it, y) val result = x.minus(it, y)
a.setter(pos)(it, result) a.setter(pos)(it, result)
result result
} }).asReadonly
} }
}, },
Operator(Token.Type.STARASSIGN, lastPrty) { pos, a, b -> Operator(Token.Type.STARASSIGN, lastPrty) { pos, a, b ->
Accessor { Accessor {
val x = a.getter(it) val x = a.getter(it).value
val y = b.getter(it) val y = b.getter(it).value
x.mulAssign(it, y) ?: run { (x.mulAssign(it, y) ?: run {
val result = x.mul(it, y) val result = x.mul(it, y)
a.setter(pos)(it, result) a.setter(pos)(it, result)
result result
} }).asReadonly
} }
}, },
Operator(Token.Type.SLASHASSIGN, lastPrty) { pos, a, b -> Operator(Token.Type.SLASHASSIGN, lastPrty) { pos, a, b ->
Accessor { Accessor {
val x = a.getter(it) val x = a.getter(it).value
val y = b.getter(it) val y = b.getter(it).value
x.divAssign(it, y) ?: run { (x.divAssign(it, y) ?: run {
val result = x.div(it, y) val result = x.div(it, y)
a.setter(pos)(it, result) a.setter(pos)(it, result)
result result
}).asReadonly
}
} }
}, },
Operator(Token.Type.PERCENTASSIGN, lastPrty) { pos, a, b -> Operator(Token.Type.PERCENTASSIGN, lastPrty) { pos, a, b ->
Accessor { Accessor {
val x = a.getter(it) val x = a.getter(it).value
val y = b.getter(it) val y = b.getter(it).value
x.modAssign(it, y) ?: run { (x.modAssign(it, y) ?: run {
val result = x.mod(it, y) val result = x.mod(it, y)
a.setter(pos)(it, result) a.setter(pos)(it, result)
result result
}).asReadonly
}
} }
}, },
// logical 1 // logical 1
Operator.simple(Token.Type.OR, ++lastPrty) { ctx, a, b -> a.logicalOr(ctx,b) }, Operator.simple(Token.Type.OR, ++lastPrty) { ctx, a, b -> a.logicalOr(ctx, b) },
// logical 2 // logical 2
Operator.simple(Token.Type.AND, ++lastPrty) { ctx, a, b -> a.logicalAnd(ctx,b) }, Operator.simple(Token.Type.AND, ++lastPrty) { ctx, a, b -> a.logicalAnd(ctx, b) },
// bitwise or 2 // bitwise or 2
// bitwise and 3 // bitwise and 3
// equality/ne 4 // equality/ne 4
@ -819,10 +835,10 @@ class Compiler(
Operator.simple(Token.Type.GT, lastPrty) { c, a, b -> ObjBool(a.compareTo(c, b) > 0) }, Operator.simple(Token.Type.GT, lastPrty) { c, a, b -> ObjBool(a.compareTo(c, b) > 0) },
// shuttle <=> 6 // shuttle <=> 6
// bit shifts 7 // bit shifts 7
Operator.simple(Token.Type.PLUS, ++lastPrty) { ctx, a, b -> a.plus(ctx,b) }, Operator.simple(Token.Type.PLUS, ++lastPrty) { ctx, a, b -> a.plus(ctx, b) },
Operator.simple(Token.Type.MINUS, lastPrty) { ctx, a, b -> a.minus(ctx,b) }, Operator.simple(Token.Type.MINUS, lastPrty) { ctx, a, b -> a.minus(ctx, b) },
Operator.simple(Token.Type.STAR, ++lastPrty) { ctx, a, b -> a.mul(ctx,b) }, Operator.simple(Token.Type.STAR, ++lastPrty) { ctx, a, b -> a.mul(ctx, b) },
Operator.simple(Token.Type.SLASH, lastPrty) { ctx, a, b -> a.div(ctx, b) }, Operator.simple(Token.Type.SLASH, lastPrty) { ctx, a, b -> a.div(ctx, b) },
Operator.simple(Token.Type.PERCENT, lastPrty) { ctx, a, b -> a.mod(ctx, b) }, Operator.simple(Token.Type.PERCENT, lastPrty) { ctx, a, b -> a.mod(ctx, b) },
) )

View File

@ -12,10 +12,10 @@ import kotlin.math.roundToLong
data class WithAccess<T>(var value: T, val isMutable: Boolean) data class WithAccess<T>(var value: T, val isMutable: Boolean)
data class Accessor( data class Accessor(
val getter: suspend (Context) -> Obj, val getter: suspend (Context) -> WithAccess<Obj>,
val setterOrNull: (suspend (Context, Obj) -> Unit)? val setterOrNull: (suspend (Context, Obj) -> Unit)?
) { ) {
constructor(getter: suspend (Context) -> Obj) : this(getter, null) constructor(getter: suspend (Context) -> WithAccess<Obj>) : this(getter, null)
fun setter(pos: Pos) = setterOrNull ?: throw ScriptError(pos, "can't assign value") fun setter(pos: Pos) = setterOrNull ?: throw ScriptError(pos, "can't assign value")
} }
@ -35,13 +35,13 @@ sealed class Obj {
/** /**
* Get instance member traversing the hierarchy if needed. Its meaning is different for different objects. * Get instance member traversing the hierarchy if needed. Its meaning is different for different objects.
*/ */
fun getInstanceMemberOrNull(name: String): Obj? { fun getInstanceMemberOrNull(name: String): WithAccess<Obj>? {
members[name]?.let { return it.value } members[name]?.let { return it }
parentInstances.forEach { parent -> parent.getInstanceMemberOrNull(name)?.let { return it } } parentInstances.forEach { parent -> parent.getInstanceMemberOrNull(name)?.let { return it } }
return null return null
} }
fun getInstanceMember(atPos: Pos, name: String): Obj = fun getInstanceMember(atPos: Pos, name: String): WithAccess<Obj> =
getInstanceMemberOrNull(name) getInstanceMemberOrNull(name)
?: throw ScriptError(atPos, "symbol doesn't exist: $name") ?: throw ScriptError(atPos, "symbol doesn't exist: $name")
@ -52,7 +52,7 @@ sealed class Obj {
// note that getInstanceMember traverses the hierarchy // note that getInstanceMember traverses the hierarchy
// instance _methods_ are our ObjClass instance: // instance _methods_ are our ObjClass instance:
// note that getInstanceMember traverses the hierarchy // note that getInstanceMember traverses the hierarchy
objClass.getInstanceMember(context.pos, name).invoke(context, this, args) objClass.getInstanceMember(context.pos, name).value.invoke(context, this, args)
// methods that to override // methods that to override
@ -102,9 +102,7 @@ sealed class Obj {
context.raiseNotImplemented() context.raiseNotImplemented()
} }
open suspend fun assign(context: Context, other: Obj): Obj { open suspend fun assign(context: Context, other: Obj): Obj? = null
context.raiseNotImplemented()
}
/** /**
* a += b * a += b
@ -143,7 +141,7 @@ sealed class Obj {
suspend fun <T> sync(block: () -> T): T = monitor.withLock { block() } suspend fun <T> sync(block: () -> T): T = monitor.withLock { block() }
fun readField(context: Context, name: String): Obj = getInstanceMember(context.pos, name) fun readField(context: Context, name: String): WithAccess<Obj> = getInstanceMember(context.pos, name)
fun writeField(context: Context, name: String, newValue: Obj) { fun writeField(context: Context, name: String, newValue: Obj) {
willMutate(context) willMutate(context)
@ -152,7 +150,7 @@ sealed class Obj {
} }
fun createField(name: String, initialValue: Obj, isMutable: Boolean = false, pos: Pos = Pos.builtIn) { fun createField(name: String, initialValue: Obj, isMutable: Boolean = false, pos: Pos = Pos.builtIn) {
if (name in members || parentInstances.any<Obj> { name in it.members }) if (name in members || parentInstances.any { name in it.members })
throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes")
members[name] = WithAccess(initialValue, isMutable) members[name] = WithAccess(initialValue, isMutable)
} }
@ -169,6 +167,9 @@ sealed class Obj {
suspend fun invoke(context: Context, atPos: Pos, thisObj: Obj, args: Arguments): Obj = suspend fun invoke(context: Context, atPos: Pos, thisObj: Obj, args: Arguments): Obj =
callOn(context.copy(atPos, args = args, newThisObj = thisObj)) callOn(context.copy(atPos, args = args, newThisObj = thisObj))
val asReadonly: WithAccess<Obj> by lazy { WithAccess(this, false) }
val asMutable: WithAccess<Obj> by lazy { WithAccess(this, true) }
companion object { companion object {
inline fun <reified T> from(obj: T): Obj { inline fun <reified T> from(obj: T): Obj {

View File

@ -6,4 +6,6 @@ package net.sergeych.ling
data class StoredObj( data class StoredObj(
var value: Obj?, var value: Obj?,
val isMutable: Boolean = false val isMutable: Boolean = false
) ) {
val asAccess: WithAccess<Obj>? get() = value?.let { WithAccess(it, isMutable) }
}

View File

@ -149,8 +149,9 @@ class ScriptTest {
assertFailsWith<ScriptError> { assertFailsWith<ScriptError> {
context.eval("a = 10") context.eval("a = 10")
} }
assertEquals(10, context.eval("b = a - 3 - 4; b").toInt()) assertEquals(17, context.eval("a").toInt())
assertEquals(10, context.eval("b").toInt()) assertEquals(5, context.eval("b = a - 7 - 5").toInt())
assertEquals(5, context.eval("b").toInt())
} }
@Test @Test
@ -632,5 +633,37 @@ class ScriptTest {
assertEquals(2, ctx.eval("x %= 5").toInt()) assertEquals(2, ctx.eval("x %= 5").toInt())
} }
@Test
fun testVals() = runTest {
val cxt = Context()
cxt.eval("val x = 11")
assertEquals(11, cxt.eval("x").toInt())
assertFails { cxt.eval("x = 12") }
assertFails { cxt.eval("x += 12") }
assertFails { cxt.eval("x -= 12") }
assertFails { cxt.eval("x *= 2") }
assertFails { cxt.eval("x /= 2") }
assertFails { cxt.eval("x++") }
assertFails { cxt.eval("++x") }
assertFails { cxt.eval("x--") }
assertFails { cxt.eval("--x") }
assertEquals(11, cxt.eval("x").toInt())
}
// @Test
// fun testMultiAssign() = runTest {
// assertEquals(
// 7,
// eval("""
// var x = 10
// var y = 2
// (x = 1) = 5
// println(x)
// println(y)
// x + y
// """.trimIndent()).toInt()
// )
// }
//
} }

View File

@ -38,7 +38,7 @@ data class DocTest(
} }
val detailedString by lazy { val detailedString by lazy {
val codeWithLines = sourceLines.withIndex().map { (i, s) -> "${i + line}: $s" }.joinToString("\n") val codeWithLines = sourceLines.withIndex().map { (i, s) -> "${i + line + 1}: $s" }.joinToString("\n")
var result = "$this\n$codeWithLines\n" var result = "$this\n$codeWithLines\n"
if (expectedOutput.isNotBlank()) if (expectedOutput.isNotBlank())
result += "--------expected output--------\n$expectedOutput\n" result += "--------expected output--------\n$expectedOutput\n"