Create your own dsl

In software development I encounter problems with creating user interfaces for manage logical chains.
More complex chain of logic cause more difficult to develop, test, and debug resulting interface.
That interfaces are hard to understand without help of man, who participated in the design.

Instead of complex interface is to try to use DSL.
This require a little more skill from the end user but in return it will eliminate the overloaded ui.
It provide a more flexible control over logic at lower labor costs.
A much easier debugging, testing, and auto-testing.

Consider the example of pethouse. We have the following classes:

class Food {
    Integer size
}
class Pet {
    String name
    Long age
    Closure hello
}
@DSLRoot("pet_house")
class PetHouse {
    String name
    Pet master
    @DSLTypeHint(Pet) List<Pet> pet
    @DSLTypeHint(Food) Map<String, Food> food
}

To create pethouse we need one screen to PetHouse and two dialogue to Pet and Food.
The difficulties begin with the behavior of hello method for each pet.
How many screens needed if we want to pet shouted “Hello!” only when there is enough food for it?
And if we want to check whether a particular type of food?

All this logic is easy to describe in the DSL (groovy):

pet_house {
    name "My pet house"
    food {
        cat_food {size 12}
    }
    pet {
        name "Cat"
        hello {
            if (food["cat_food"].size > 0) {
                return "Hello"
            } else {
                return "I need food!!!"
            }
        }
    }
}

Plain and simple.

There is DSLParser with specification to help write your own DSL.
Also, it requires Reflections which can be replaced simply.

DSLParser

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DSLRoot {
    String value();
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface DSLTypeHint {
    Class value();
}

class DSLInvocationError extends Exception {
    String reason
    String probablyLocation
    Exception suppressedException

    DSLInvocationError(String reason, String probablyLocation, Exception suppressedException) {
        super("${probablyLocation}: ${reason}")
        this.reason = reason
        this.probablyLocation = probablyLocation
        this.suppressedException = suppressedException
    }
}

class DSLParser {
    String location = DSLParser.package.name
    @Lazy
    volatile Map<String, Closure> constructors = constructors()
    @Lazy
    volatile GroovyShell shell = new GroovyShell()

    def parse(String data) {
        try {
            def userScript = shell.parse(data, "UserScript")
            userScript.binding = new Binding(constructors)
            userScript.run()
        } catch (Exception e) {
            def find = e.getStackTrace().find { it.fileName == "UserScript" }
            throw new DSLInvocationError(e.getMessage(), "Probably error location at line ${find?.lineNumber}", e)
        }
    }

    @Newify
    private Map<String, Closure> constructors() {
        def result = [:]
        new Reflections(location, TypeAnnotationsScanner.new()).getTypesAnnotatedWith(DSLRoot).each {
            result[it.getAnnotation(DSLRoot).value()] = { Closure c ->
                def root = it.newInstance()
                invokeClosureForProperty(root, c)
                return root
            }
        }
        return result
    }

    def invokeClosureForProperty(Object toWrap, Closure closure) {
        closure.setDelegate(new DSLWrapper(obj: toWrap))
        closure.setResolveStrategy(DELEGATE_ONLY)
        closure.call()
        return toWrap
    }

    class DSLWrapper {
        Object obj

        @Override
        Object invokeMethod(String name, Object args) {
            try {
                return obj.invokeMethod(name, args)
            } catch (MissingMethodException ignored) {
                //noinspection GroovyAssignabilityCheck
                def arg = args[0]
                //just process map directly
                if (obj instanceof Map) {
                    obj.put(name, arg)
                } else if (obj.hasProperty(name)) {
                    MetaBeanProperty property = obj.metaClass.properties.find { it.name == name } as MetaBeanProperty
                    def hint = property.field.field.getAnnotation(DSLTypeHint)?.value()
                    applyProperty(property, arg, hint)
                } else {
                    throw new IllegalArgumentException("There is no method or property like '${name}' in '${obj.class.name}'")
                }
            }
            return null
        }

        private void applyProperty(MetaBeanProperty property, arg, Class hint) {
            if (property.getType().isAssignableFrom(Map.class)) {
                Map map = (property.getProperty(obj) ?: new HashMap<>()) as Map
                convertToProperty(HashMap, arg).each { Map.Entry entry ->
                    map.put(entry.key, convertToProperty(hint, entry.value))
                }
                property.setProperty(obj, map)
            } else if (property.getType().isAssignableFrom(List.class)) {
                def list = property.getProperty(obj) ?: new ArrayList<>()
                list << convertToProperty(hint, arg)
                property.setProperty(obj, list)
            } else if (property.getType().isAssignableFrom(Closure)) {
                property.setProperty(obj, arg)
            } else {
                property.setProperty(obj, convertToProperty(property.getType(), arg))
            }
        }

        def convertToProperty(Class type, Object value) {
            if (value instanceof Closure) {
                if (!type) {throw new IllegalArgumentException("I can't see such a DSLHint around")}
                return invokeClosureForProperty(type.newInstance(), value)
            } else {
                return value.asType(type)
            }
        }

    }
}

DSLParserSpec

class DSLParserTest extends BasicSpec {
    def parser

    def setup() {
        parser = new DSLParser()
        parser.location = "dsl.test"
    }

    def "process simple objects"() {
        when:
            def house = parser.parse """
                pet_house {
                    name "${houseName}"
                    master {
                        name "${houseMaster}"
                    }
                }
            """
        then:
            house.name == houseName
            house.master.name == houseMaster
        where:
            houseName = "Test name"
            houseMaster = "Mr. Meow"
    }

    def "process list of elements and closures"() {
        when:
            def house = parser.parse """
                pet_house {
                    pet {
                        name "${petName1}"
                        hello {return "Hello from ${petName1}"}
                    }
                    pet {
                        name "${petName2}"
                        hello {return "Hello from ${petName2}"}
                    }
                }
            """
        then:
            house.pet.size() == 2
            house.pet[0].name == petName1
            house.pet[0].hello() == "Hello from ${petName1}"
            house.pet[1].name == petName2
            house.pet[1].hello() == "Hello from ${petName2}"
        where:
            petName1 = "Nyan"
            petName2 = "Nyan the second"
    }

    def "process maps"() {
        when:
            def house = parser.parse """
                    pet_house {
                       food {
                            ${food1.name} {
                                size "${food1.size}"
                            }
                            ${food2.name} {
                                size "${food2.size}"
                            }
                       }
                    }
                """
        then:
            house.food[food1.name].size == food1.size
            house.food[food2.name].size == food2.size
        where:
            food1 = [name: "cat_food", size: 32]
            food2 = [name: "dog_food", size: 34]
    }

    def "guess line with error for you"() {
        when:
            def house = parser.parse """
                    pet_house {
                        prop_non_exists "yep"
                    }
                """
        then:
            def error = thrown(DSLInvocationError)
            error.probablyLocation.startsWith("Probably error location at line 3")
    }

    def "and something nice"() {
        when:
            def house = parser.parse """
                    pet_house {
                        10.times {
                            def id = it
                            pet {
                                hello {id}
                            }
                        }
                    }
                """
        then:
            house.pet.sum {it.hello()} == 45

    }

}

One thought on “Create your own dsl

Leave a Reply

Your email address will not be published. Required fields are marked *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax