After some thought of the problem, I realized, that these two requirements can't be solved in Kotlin itself, therefore no pure syntactical solution is possible in the current form introduced above. However, there are a few options which may produce close enough syntax and addresses one or both problems at the same time.
Option 1: Parameters
This solution is quite simple and ugly, adding the awful "where-is-the-closing-parenthesis" anomaly. It simply moves the position property into constructor:
start(random {
rect(49, 46, 49, 47)
rect(50, 47, 51, 48)
point(51, 49)
}) {
windDirection to NORTH
boat turn (BEAM_REACH at STARBOARD)
}
This is simple in code:
fun start(pos : StartPosition, op: StartConfigBuilder.() -> Unit) : StartConfigBuilder
= StartConfigBuilder(pos).apply(op)
and creates top level builder functions for the position implementations:
fun random( op : RandomStartPositionBuilder.() -> Unit) = RandomStartPositionBuilder().apply(op).build()
class RandomStartPositionBuilder {
private val startZoneAreas = mutableListOf<Area>()
fun rect(startRow: Int, startColumn: Int, endRow: Int = startRow, endColumn: Int) =
startZoneAreas.add(Area(startRow, startColumn, endRow, endColumn))
fun point(row: Int, column: Int) = startZoneAreas.add(Area(row, column))
fun build() = RandomStartPosition(if (startZoneAreas.isEmpty()) null else Zone(startZoneAreas))
}
fun user( op : UserStartPositionBuilder.() -> Unit) = UserStartPositionBuilder().apply(op).build()
class UserStartPositionBuilder {
fun build() = UserStartPosition()
}
Although this solves both required and only one problems on edit time, makes the DSL much harder to read and we loose the elegance of the DSL tools. It will become even more messy if more than one properties have to be moved into the constructor or as the internal object (position) becomes more complicated.
Option 2: Infix function
This solution moves the required complex field outside the block (this is the "nasty" part) and uses it as an infix function:
start {
windDirection to NORTH
boat turn (BEAM_REACH at STARBOARD)
} position random {
rect(49, 46, 49, 47)
rect(50, 47, 51, 48)
point(51, 49)
}
or
start {
windDirection to NORTH
boat turn (BEAM_REACH at STARBOARD)
} position user {
}
This solution solves the "only one" problem, but not the "exactly one".
To achieve this, I modified the builders:
//Note, that the return value is the builder: at the end, we should call build() later progmatically
fun start(op: StartConfigBuilder.() -> Unit) : StartConfigBuilder = StartConfigBuilder().apply(op)
class StartConfigBuilder {
private var position: StartPosition = DEFAULT_START_POSITION
private var windDirectionVal: InitialWindDirection = RandomInitialWindDirection()
val windDirection = InitialWindDirectionBuilder()
val boat = InitialHeadingBuilder()
infix fun position(pos : StartPosition) : StartConfigBuilder {
position = pos
return this
}
fun build() = StartConfig(position, windDirection.value, boat.get())
}
// I have to move the factory function top level
fun random( op : RandomStartPositionBuilder.() -> Unit) = RandomStartPositionBuilder().apply(op).build()
class RandomStartPositionBuilder {
private val startZoneAreas = mutableListOf<Area>()
fun rect(startRow: Int, startColumn: Int, endRow: Int = startRow, endColumn: Int) =
startZoneAreas.add(Area(startRow, startColumn, endRow, endColumn))
fun point(row: Int, column: Int) = startZoneAreas.add(Area(row, column))
fun build() = RandomStartPosition(if (startZoneAreas.isEmpty()) null else Zone(startZoneAreas))
}
// Another implementation
fun user( op : UserStartPositionBuilder.() -> Unit) = UserStartPositionBuilder().apply(op).build()
class UserStartPositionBuilder {
fun build() = UserStartPosition()
}
This solves the problem of "only-one" implementation in an almost elegant way, but gives no answer to the "required property" option. So it is good when default value could be applied, but still gives only parse time exception when the position is missing.
Options 3: Chain of infix functions
This solution is a variant of the previous. To address the required issue of the previous, we use a variable and an intermediate class:
var start : StartWithPos? = null
class StartWithoutPos {
val windDirection = InitialWindDirectionBuilder()
val boat = InitialHeadingBuilder()
}
class StartWithPos(val startWithoutPos: StartWithoutPos, pos: StartPosition) {
}
fun start( op: StartWithoutPos.() -> Unit): StartWithoutPos {
val res = StartWithoutPos().apply(op)
return res
}
infix fun StartWithoutPos.position( pos: StartPosition): StartWithPos {
return StartWithPos(this, pos)
}
Then we could write the following statement in DSL:
start = start {
windDirection to NORTH
boat heading NORTH
} position random {
}
This would solve both problems, but with the cost of an additional variable assignment.
All three solutions work, adds some dirt to DSL, but one might chose which fits better.