In my previous post, I talked about using Value Objects in Objective-C projects. I gave an example of a Ruby DSL that could be used to specify the object’s properties so the code could be generated.
In this post, I’ll go through some Ruby code that can turn that DSL into an Objective-C header and implementation file.
The Domain-Specific Language
The DSL will provide a way to specify the name of the class, as well as each of the properties for the class. Here’s an example:
model "Person" do
property "name", type: "NSString *"
property "age", type: "NSNumber *"
property "height", type: "NSNumber *"
property "weight", type: "NSNumber *"
property "alive", type: "BOOL", default: "YES", getter: "isAlive"
end
The DSL is very simple, providing only four keywords: model
, property
, headers
, and enums
. Everything else is conveyed in the arguments to the property
method.
The property
method takes two arguments. The first is the name of the property, and the second is a hash of additional arguments, with the only required key being the :type
. In addition to the :type,
you can also specify :default
, :getter
, and :comment
.
The enums
and headers
methods both take strings that will be copied directly into the generated header file. For example:
headers <<-EOS
#import "SomeOtherFile.h"
EOS
model "Person" do
property "name", type: "NSString *"
end
The Context
We’ll start with a class that provides the keywords used in the DSL, called Context
.
class Context
attr_reader :properties,
:class_name,
:headers_str,
:enums_str
def initialize
@properties = []
end
def model(model_class_name, &block)
@class_name = model_class_name
instance_eval(&block)
end
def property(name, args = {})
@properties << args.merge(name: name)
end
def enums(str)
@enums_str = str
end
def headers(str)
@headers_str = str
end
end
The only thing of any interest here is in the model
method. It takes a block that is instance_eval’d so that the Context
object’s property
method can be called from inside the block.
The Model Definition
Next, we’ll define a class called ModelDefinition
that will use the Context
along with some ERB templates to provide the contents of the generated files.
This one is a little longer, so I’ll break it up into chunks.
class ModelDefinition
def initialize(text)
@context = Context.new
@context.instance_eval(text)
end
def header
erb_template = ERB.new(header_template, nil, '-')
erb_template.result(binding)
end
def implementation
erb_template = ERB.new(implementation_template, nil, '-')
erb_template.result(binding)
end
def class_name
context.class_name
end
private
attr_reader :context
.
.
.
In the initializer, the passed in text, which is expected to be the model definition using the DSL, is instance_eval’d against an a Context
object. From that point forward, the context knows all of the details of the model being generated.
Next, we’ll define a couple of helper methods that can be used by the ERB templates to generate the Objective-C @property
lines in the header file, as well as some reasonable defaults that can be used for properties of some standard types.
def property_definition(readonly, args)
parts = ["@property"]
readonly_string = readonly ? ", readonly" : ""
property_options = ["nonatomic"]
if args[:type].include?("*")
property_options << (args[:type] =~ /^NS/ ? "copy" : "strong")
end
if readonly
property_options << "readonly"
end
if args[:getter]
property_options << "getter=#{args[:getter]}"
end
parts << "(#{property_options.join(", ")})"
parts << args[:type]
parts << args[:name]
line = parts.join(" ").gsub("* ", "*")
"#{line};#{args[:comment] ? " // #{args[:comment]}" : nil}"
end
def default_value(args)
if args[:default]
args[:default]
else
case args[:type]
when "BOOL"
"NO"
when /NSArray/
"@[]"
when /NSDictionary/
"@{}"
when /\*/
"nil"
else
"0"
end
end
end
.
.
.
Next is a method that returns the ERB template for the header (.h
) file.
def header_template
template = <
<%= context.headers_str %>
<%- end -%>
<%- if context.enums_str -%>
<%= context.enums_str %>
<%- end -%>
@interface <%= context.class_name %>Builder : MTLModel
<% context.properties.each do |prop| -%>
<%= property_definition(false, prop) %>
<% end -%>
@end
@interface <%= context.class_name %> : MTLModel
<% context.properties.each do |prop| -%>
<%= property_definition(true, prop) %>
<% end -%>
- (instancetype)init;
- (instancetype)initWithBuilder:(<%= context.class_name %>Builder *)builder;
+ (instancetype)makeWithBuilder:(void (^)(<%= context.class_name %>Builder *))updateBlock;
- (instancetype)update:(void (^)(<%= context.class_name %>Builder *))updateBlock;
@end
EOS
end
.
.
.
And finally, a method that returns the ERB template for the implementation (.m
) file.
def implementation_template
template = <Builder
- (instancetype)init {
if (self = [super init]) {
<%- context.properties.each do |prop| -%>
_<%= prop[:name] %> = <%= default_value(prop) %>;
<%- end -%>
}
return self;
}
@end
@implementation <%= context.class_name %>
- (instancetype)initWithBuilder:(<%= context.class_name %>Builder *)builder {
if (self = [super init]) {
<%- context.properties.each do |prop| -%>
_<%= prop[:name] %> = builder.<%= prop[:name] %>;
<%- end -%>
}
return self;
}
- (<%= class_name %>Builder *)makeBuilder {
<%= context.class_name %>Builder *builder = [<%= context.class_name %>Builder new];
<%- context.properties.each do |prop| -%>
builder.<%= prop[:name] %> = _<%= prop[:name] %>;
<%- end -%>
return builder;
}
- (instancetype)init {
<%= context.class_name %>Builder *builder = [<%= context.class_name %>Builder new];
return [self initWithBuilder:builder];
}
+ (instancetype)makeWithBuilder:(void (^)(<%= context.class_name %>Builder *))updateBlock {
<%= context.class_name %>Builder *builder = [<%= context.class_name %>Builder new];
updateBlock(builder);
return [[<%= context.class_name %> alloc] initWithBuilder:builder];
}
- (instancetype)update:(void (^)(<%= context.class_name %>Builder *))updateBlock {
<%= context.class_name %>Builder *builder = [self makeBuilder];
updateBlock(builder);
return [[<%= context.class_name %> alloc] initWithBuilder:builder];
}
@end
EOS
end
end
The Script
Lastly, we need a script that makes use of the two classes we defined above:
def replace_file(path, content)
File.open(path, "w") do |file|
file.write(content)
end
end
ARGV.each do |filename|
model_definition = ModelDefinition.new(File.read(filename))
replace_file("output/#{model_definition.class_name}.h",
model_definition.header)
replace_file("output/#{model_definition.class_name}.m",
model_definition.implementation)
end
This script will iterate over each filename passed into it. It reads the contents of each file and generates a header/implementation file for each one, writing the generated files into an output
directory.
You can see the entire script here.
More Than Just Value Objects
Little one-off DSLs like this can be very useful for all sorts of code generation purposes—from generating production code to populating test data for an acceptance test.
Here, I’ve shown how easy it is to put together a script for generating Objective-C classes for immutable value objects. Use it as a starting point, expand it, or convert it to generate your own code for your own Objective-C project, or for an entirely different programming language.
hi,how use the immutables.rb?
can you give a demo?
Yes, you run the script passing the names of the files to process. For example, to run it against a single file you’d run:
Or to process a whole directory of files:
ruby immutables.rb userModel.rb
immutables.rb:191:in `initialize’: No such file or directory @ rb_sysopen – output/Person.h (Errno::ENOENT)
from immutables.rb:191:in `open’
from immutables.rb:191:in `replace_file’
from immutables.rb:199:in `block in ‘
from immutables.rb:196:in `each’
from immutables.rb:196:in `’
userModel.rb
model “Person” do
property “name”, type: “NSString *”
property “age”, type: “NSNumber *”
property “height”, type: “NSNumber *”
property “weight”, type: “NSNumber *”
property “alive”, type: “BOOL”, default: “YES”, getter: “isAlive”
end
I think you just need to create a directory called “output” for it to put the files in. Or you could update the script to write out the files to the current directory, which is probably what I should have done in the first place.