I'm writing a DSL that is heavily scope-based, bec...
# library-development
c
I'm writing a DSL that is heavily scope-based, because it contains many functions that have the same name but do different things in different contexts (not by choice :/). Because of this, everything from the DSL takes a trailing lambda. However, some of these functions can only be configured once. For example, this is incorrect (
foo
only accepts one configuration):
Copy code
foo {
    option1(true)
    option1(false) // illegal, 'foo' has already been configured!
}
but this is correct (
and
combines multiple configurations in a single one):
Copy code
foo {
    and {
        option1()
        option2()
    }
}
the difference is important because there is multiple ways to combine configuration (
or
…). Otherwise, I would have just made
and
the default. Is there a way to make the first example not compile? Even better if the error message can point users to
and
and
or
.
a
It looks like a tricky problem! I don't think there's any way to stop the first example from compiling.
Would it make more sense to have
and {}
as an infix function?
Copy code
foo { option1() and option2() }
btw, your examples remind me a bit of the Parsus library, where tokens can be combined. Each combination is defined as a property of a class. Maybe that's good for some inspiration. JsonParser
j
This problem exists in Gradle, I doubt you can solve this in compile time without a compiler plugin
j
Maybe consider making
foo
accept the configuration directly instead of a lamda. That way it's limited to only one. It changes the DSL of course though.
Copy code
foo(
    option1(true)
)
or
Copy code
foo(
    and {
        option1()
        option2()
    }
)
c
Would it make more sense to have
and {}
as an infix function?
No, there could be many, many more than 2 options.
Maybe consider making
foo
accept the configuration directly instead of a lamda. That way it's limited to only one. It changes the DSL of course though.
I thought of this, but there are a few problems: • the function cannot introduce a receiver anymore, so I lose type safety for the options • this forces introducing a variant of
option1()
that returns a value, whereas the DSL-variant mutates the DSL itself. Because that new variant will be top-level without a context, it will become extremely easy to call it when meaning to call the receiver variant, which would no-op, so your configuration would be swallowed into the void if you import the wrong variant 😕
🤔 1
a
is the data encodable, to JSON or similar?
c
It gets encoded towards the end of processing, but that's way later.
a
would it help to separate the option value definitions from the combinatorics?
Copy code
val options = object : FooOptions {
  override val option1 by true
}

val combiner = object: FooCombiner<FooOptions> { 
  override fun combine(opts: FooOptions): FooCombined = ...
}

doTheThing(options, combiner)
c
I'm not sure how this would be used, but it's seem extremely verbose?
m
Depends how much control you have over the compilation environment:
Copy code
fun main() {
    foo {
        option1(true)
        option1(false)
    }
}

@DslMarker
annotation class MyDsl

@MyDsl
class FooBuilder {
    internal class ConfigurationFinished : RuntimeException()
}

// use FooBuilder.() -> Unit if setting an option is optional
fun foo(block: FooBuilder.() -> Nothing): Unit {
    val builder = FooBuilder()
    try {
        builder.block()
    } catch (ignored: FooBuilder.ConfigurationFinished) {
        // actually we expect this exception
    }
}

fun FooBuilder.option1(something: Boolean): Nothing {
    println("configuring $something")
    // configure something
    throw FooBuilder.ConfigurationFinished()
}
This will at least create a warning and with "warning as error" compiler option you could make the build fail.