0

Kotlin DSL support is great, but I ran into two scenarios I can only add workaround. Both workaround has its major drawback as they enforce constraints only at execution time.

First constraint: required parameter

I would like to write something like this:

start {
    position {
        random {
            rect(49, 46, 49, 47)
            rect(50, 47, 51, 48)
            point(51, 49)
        }
    }
}

where position is a required parameter. My approach is to set the position to null at startup and checking it when building the start object.

Second constraint: one of many

I would like to allow exactly one of several possible sub objects:

start {
    position {
        random {
            [parameters of random assign]
        }
    }
}

or

start {
    position {
        user {
            [parameters of user assign]
        }
    }
}

I have a feeling that I reached the edge of possibilities of the Kotlin DSL toolkit, because this requirements are also only compile time validated in the core language as well.

Any idea?

Uwe Keim
  • 39,551
  • 56
  • 175
  • 291
Balage1551
  • 1,037
  • 1
  • 10
  • 28
  • 1
    How about `position { }` and `position { }` ? – Jonas Wilms Jan 06 '19 at 13:40
  • Can you add more context to your question? I am assuming you are implementing some sort of type safe builder? Maybe show the function definitions. – nPn Jan 06 '19 at 13:54
  • @JonasWilms That's an option, but it publish some nasty syntax (I plan this DSL for non-developers). Also, it would not offer different builder context based on generic parameter. – Balage1551 Jan 06 '19 at 17:43
  • @nPn I'll give some (soon), but my current builders doesn't handle the problems. – Balage1551 Jan 06 '19 at 17:44
  • @Balage1551 "non-developers" will not be able to interpret the Kotlin type errors either, so I would use runtime errors and unit tests here – Jonas Wilms Jan 06 '19 at 17:55

2 Answers2

0

You can take inspiration from Kotlin own HTML DSL. For mandatory arguments use simple functions with arguments, not function literal with a receiver.

Your DSL will look something like this:

start(
    position {// This is mandatory
        random {// This is not

        }
    }
)

And your start builder:

fun start(position: Position): Start {
    val start = Start(position)
    ...
    return start
}

Use same approach for position().

Alexey Soshin
  • 16,718
  • 2
  • 31
  • 40
  • Thanks for answer. I came to the same conclusion (see the Option 1 in my answer. It has some clarity drawbacks when the required value is complex, so I tried some other ways, too. Each has its own cost, and each may fit different scenarios better. I am still hasn't decided which fits my goal best. – Balage1551 Jan 06 '19 at 21:57
0

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.

Balage1551
  • 1,037
  • 1
  • 10
  • 28