Further to my previous post I thought I’d share the approach I’ve been
using to compose DSLs in JavaScript. If you want a more involved example, check
out the currently in-development Forms
module for Ojay.
For this example I’m going to be writing a simple permissions language that sets up rules about when certain methods may be called. It could be applied to a system for playing monopoly:
TheRulesAre(function() { with(this) {
a(Player).mayNot('buyProperty').unless(function(player, property) {
return property.owner === null &&
player.funds >= property.price;
});
a(Property).mayNot('addHouse').when(it().isMortgaged());
}});
This illustrates a couple of principles when writing your DSL. First, I like to isolate DSL-based code inside a function. This allows cleaner separation from the rest of your program, so you can write helper functions inside the DSL function and not have them polute the global namespace. Second, you should write some test code first: it will make writing the DSL implementation much easier.
Let’s look at the pattern of the language we’re creating:
a(Class).mayNot(methodName).unless(condition)
Each rule takes a class and needs to modify one of its instance methods to check
a condition before allowing the method to be called. The first step in
implementing the DSL is to identify the root-level methods, which in this case
are simply a()
. This method should take a class and return an object with a
mayNot
method. Let’s also have methods an()
(the same as a()
) and the()
,
which will modify a specific object rather than a class.
var TheRulesAre = function(rules) {
rules.apply(TheRulesAre.Root);
};
TheRulesAre.Root = {
a: function(klass) {
return new TheRulesAre.Restriction(klass.prototype);
},
an: function(klass) {
return this.a(klass);
},
the: function(object) {
return new TheRulesAre.Restriction(object);
}
};
So now that we’ve set up the root methods, we need to implement mayNot()
. This
method will need to take a method name and return an object with unless()
and
when()
methods. mayNot()
will not modify the class itself, it will just need
to return an object that will perform the modification. mayNot()
is provided
by the Restriction
objects returned by the root-level methods (you’ll need
JS.Class, including MethodChain
to allow the it().isMortgaged()
syntax):
TheRulesAre.Restriction = JS.Class({
initialize: function(object) {
this.object = object;
},
mayNot: function(methodName) {
return new TheRulesAre.Condition(this.object, methodName);
}
});
You see, we’re just passing the buck along, with some information about which
object and which method to modify. The final piece of the puzzle is the
Condition
class, which implements the unless()
and when()
methods. Each of
these should take a function (check
) and modify the given method. The
modification will consist of intercepting the method call and checking the
receiver and the arguments using check
. If check
returns true
, we allow
the original method to run, otherwise we throw an exception.
TheRulesAre.Condition = JS.Class({
initialize: function(object, methodName) {
this.object = object;
this.methodName = methodName;
},
unless: function(check) {
// convert MethodChain objects
if (check.toFunction) check = check.toFunction();
var object = this.object, methodName = this.methodName;
var original = object[methodName];
object[methodName] = function() {
var args = Array.from(arguments);
var allowed = check.apply(null, [this].concat(args));
if (allowed) return original.apply(this, args);
else throw new Error('method ' + methodName +
'() may not be called with these arguments');
};
},
when: function(check) {
return this.unless(function() {
return !check.apply(null, arguments);
});
}
});
And there you have it, a little mini-language for adding argument checking to method calls. The basic rule with these things is always, always write some test code first. It’s the only way to sort out your thinking about how the language should work and how it should be implemented.