Building Domain-Specific Languages
Prerequisites: Builders, Closures in depth
A DSL (domain-specific language) is a small language for one job — a build, a pipeline, a configuration, a test. Groovy's optional parentheses, closures with delegates, and metaprogramming let you build DSLs that are still ordinary Groovy code.
Command chains
When parentheses are omitted, Groovy reads a b c d as a(b).c(d):
class Turtle {
int x = 0, y = 0
List<String> log = []
Map move(String direction) {
[by: { int steps ->
switch (direction) {
case 'left' -> x -= steps
case 'right' -> x += steps
case 'up' -> y += steps
case 'down' -> y -= steps
}
log << "$direction $steps -> ($x, $y)"
this
}]
}
}
def turtle = new Turtle()
turtle.with {
move 'right' by 3
move 'up' by 2
move 'left' by 1
}
turtle.log.each { println it }
right 3 -> (3, 0)
up 2 -> (3, 2)
left 1 -> (2, 2)
move 'right' by 3 is move('right').by(3): move returns a map with a
by closure, which is then called with 3.
Configuration DSLs
class ServerSpec {
String host = 'localhost'
int port = 80
Map<String, String> routes = [:]
void host(String h) { host = h }
void port(int p) { port = p }
void routes(@DelegatesTo(RouteSpec) Closure c) {
def spec = new RouteSpec(routes: routes)
c.delegate = spec
c.resolveStrategy = Closure.DELEGATE_FIRST
c()
}
}
class RouteSpec {
Map<String, String> routes
void get(String path, String handler) { routes["GET $path"] = handler }
void post(String path, String handler) { routes["POST $path"] = handler }
}
def server(@DelegatesTo(ServerSpec) Closure c) {
def spec = new ServerSpec()
c.delegate = spec
c.resolveStrategy = Closure.DELEGATE_FIRST
c()
spec
}
def app = server {
host 'api.example.com'
port 8443
routes {
get '/users', 'UserController.list'
post '/users', 'UserController.create'
}
}
println "$app.host:$app.port"
app.routes.each { route, handler -> println "$route -> $handler" }
api.example.com:8443
GET /users -> UserController.list
POST /users -> UserController.create
This is the same pattern as Gradle's repositories { } and Jenkins'
stages { } blocks. @DelegatesTo documents the delegate type, which
gives IDE completion and allows @CompileStatic on DSL code.
Loading DSL files safely
Users write the DSL in a separate file; your program evaluates it with a custom base class:
import org.codehaus.groovy.control.CompilerConfiguration
abstract class AlertScript extends Script {
List<String> rules = []
void alert(Map args) { rules << "when ${args.when} notify ${args.notify}" }
}
def dsl = '''
alert when: 'cpu > 90', notify: 'ops'
alert when: 'disk < 5%', notify: 'oncall'
'''
def config = new CompilerConfiguration(scriptBaseClass: AlertScript.name)
def script = new GroovyShell(this.class.classLoader, config).parse(dsl)
script.run()
script.rules.each { println it }
when cpu > 90 notify ops
when disk < 5% notify oncall
With scriptBaseClass, DSL files can call alert directly. Combine
this with a SecureASTCustomizer (see Embedding Groovy and Script
Security) when the DSL comes from users you don't fully trust.
Practice
Extend the turtle so that move 'up' by 2 times 3 repeats a move three
times.
Show solution
class Turtle {
int x = 0, y = 0
Map move(String direction) {
[by: { int steps ->
[times: { int n ->
n.times {
if (direction == 'up') y += steps
if (direction == 'right') x += steps
}
this
}]
}]
}
}
def t = new Turtle()
t.with {
move 'up' by 2 times 3
move 'right' by 1 times 4
}
println "(${t.x}, ${t.y})"
(4, 6)