Cecil
An experimental templating library designed specifically for generating source code (especially for languages that aren’t as meta-programmable as Ruby).
Cecil templates closely resemble the target source code, making templates easier to write, read, and maintain.
Features
Write templates in plain Ruby
Call Cecil::Code.generate_string
and pass it a block. Inside the block, add lines of code via backticks (or use src
if you prefer). Cecil returns your generated source code as a string.
Example
model_code = Cecil::Code.generate_string do
# Use backticks to add lines of code
`import Model from '../model'`
# Multi-line strings work, too.
# Cecil preserves indentation.
`class User extends Model {
id: number
name: string
companyId: number | undefined
}`
# use #src if you prefer to avoid backticks
src "export type Username = User['name']"
end
puts model_code
Returns:
import Model from '../model'
class User extends Model {
id: number
name: string
companyId: number | undefined
}
export type Username = User['name']
Interpolate values with Cecil's low-noise syntax
Use #[]
on the backticks to replace placeholders with actual values.
By default, placeholders start with $
and are followed by an identifier.
Positional arguments match up with placeholders in order. Named arguments match placeholders by name.
Example
field = "user"
types = ["string", "string[]"]
default_value = ["SilentHaiku", "DriftingSnowfall"]
field_class = "Model"
Cecil::Code.generate_string do
# positional arguments match placeholders by position
`let $field: $FieldType = $default`[field, types.join('|'), default_value.sort.to_json]
# named arguments match placeholders by name
`let $field: $FieldClass<$Types> = new $FieldClass($default)`[
field: field,
FieldClass: field_class,
Types: types.join('|'),
default: default_value.sort.to_json
]
end
Returns:
let user: string|string[] = ["DriftingSnowfall","SilentHaiku"]
let user: Model<string|string[]> = new Model(["DriftingSnowfall","SilentHaiku"])
"Doesn't Ruby already have string interpolation?"
Yes, but compare the readability of these two approaches:
`let $field: $FieldClass<$Types> = new $FieldClass($default)`[
field: field,
FieldClass: field_class,
Types: types.join('|'),
default: default_value.sort.to_json
]
# vs
field_types = types.join('|'),
default_json = default_value.sort.to_json
"let #{field}: #{field_class}<#{field_types}> = new #{field_class}(#{default_json})"
Indents code blocks & closes brackets automatically
A block passed to #[]
gets indented and open brackets get closed automatically.
Example
model = "User"
field_name = "name"
field_default = "Unnamed"
Cecil::Code.generate_string do
`class $Class extends Model {`[model] do
# indentation is preserved
`id: number`
`override get $field() {`[field_name] do
`return super.$field ?? $defaultValue`[field_name, field_default.to_json]
end
end # the open bracket from `... Model {` gets closed with "}"
end
Returns:
class User extends Model {
id: number
override get name() {
return super.name ?? "Unnamed"
}
}
Emit source code to other locations
When generating source code, things like functions, parameters, classes, etc, often need to be declared, imported, or otherwise setup before being used.
content_for
can be used to add content to a different location of your file.
Call content_for(some_key) { ... }
with key and a block to store content under the key you provide. Call content_for(some_key)
with the key and no block to insert your stored content at that location.
Example
models = [
{ name: 'User', inherits: 'AuthModel' },
{ name: 'Company', inherits: 'Model' },
]
Cecil::Code.generate_string do
# insert content collected for :imports
content_for :imports
models.each do |model|
``
`class $Class extends $SuperClass {`[model[:name], model[:inherits]] do
`id: number`
end
content_for :imports do
# this gets inserted above
`import $SuperClass from '../models/$SuperClass'`[SuperClass: model[:inherits]]
end
content_for :registrations do
# this gets inserted below
`$SuperClass.registerAncestor($Class)`[model[:inherits], model[:name]]
end
end
``
# insert content collected for :registrations
content_for :registrations
end
Returns:
import AuthModel from '../models/AuthModel'
import Model from '../models/Model'
class User extends AuthModel {
id: number
}
class Company extends Model {
id: number
}
AuthModel.registerAncestor(User)
Model.registerAncestor(Company)
Collect data as you go then use it earlier in the document
The #defer
method takes a block and waits to call it until the rest of the template is evaluated. The block's result is inserted at the location where #defer
was called.
This gives a similar ability to #content_for
, but is more flexible because you can collect any kind of data, not just source code.
Example
models = [
{ name: 'User', inherits: 'AuthModel' },
{ name: 'Company', inherits: 'Model' },
{ name: 'Candidate', inherits: 'AuthModel' },
]
Cecil::Code.generate_string do
superclasses = []
defer do
# This block gets called after the rest of the parent block is finished.
#
# By the time this block is called, the `superclasses` array is full of data
#
# Even though this block is called later, the output is added at the location where `defer` was called
`import { $SuperClasses } from '../models'`[superclasses.uniq.sort.join(', ')]
``
end
models.each do |model|
superclasses << model[:inherits] # add more strings to `superclasses`, which is used in the block above
`class $Class extends $SuperClass {}`[model[:name], model[:inherits]]
end
end
Returns:
import { AuthModel, Model } from '../models'
class User extends AuthModel {}
class Company extends Model {}
class Candidate extends AuthModel {}
Customizable syntax and behaviors
Easily customize the following features to make Cecil suit your needs/preferences:
- placeholder syntax
- auto-closing brackets
- indentation
Customizations are performed by subclassing Cecil::Code
and overriding the relevant methods.
For example, Cecil comes with Cecil::Lang::TypeScript
that you can use instead of of Cecil::Code
. It has a few JavaScript/TypeScript-specific customizations. It's a subclass of Cecil::Code
so it can be used the same way:
Cecil::Lang::TypeScript.generate_string do
# ...
end
Use cases
Things I've personally used Cecil to generate:
- serialization/deserialization code generated from from specs (e.g. OpenAPI)
- diagrams (e.g. Mermaid, PlantUML, Dot/Graphviz)
- ERDs/schemas
- state machine diagrams
- graphs
- data visualizations
- state machines generated from a list of states and transitions
- test cases generated from data that describes inputs/setup and expected outputs; because parameterized tests can be very hard to debug
- complex types because meta-programming in TypeScript can get complex quickly
Quick Reference
Reference documentation is on RubyDoc.info: gem | repo
Calling Cecil
Call
Cecil::Code.generate
/
generate_string
with a block and inside the block, use backticks or #src
to emit lines of source code.
E.g.
# returns a string
Cecil::Code.generate_string do
`function greet() {}`
`function respond() {}`
end
# outputs to $stdout
Cecil::Code.generate do
`function greet() {}`
`function respond() {}`
end
See: Methods available inside a Cecil block
Emitting source code
backticks/
#``
/#src
emit source code. E.g.:Cecil::Code.generate_string do `function greet() {}` `function respond() {}` src "function ask() {}" end # outputs: # function greet() {} # function respond() {} # function ask() {}
#[]
interpolates data into placeholders. E.g.Cecil::Code.generate_string do `function $fn() {}`["greet"] `function $fn() {}`[fn: "respond"] end # outputs: # function greet() {} # function respond() {}
#[]
{ ... }
given a block, interpolates and indents the code emitted in its block. E.g.Cecil::Code.generate_string do `function $fn() {`["greet"] do `console.log("hello")` end end # outputs: # function greet() { # console.log("hello") # }
#<<
adds code the last line of the block. E.g.Cecil::Code.generate_string do `(function ${fn}Now() {`["greet"] do `console.log("hello")` end << ')()' end # outputs: # (function greetNow() { # console.log("hello") # })()
#content_for
emits source code to different locations#defer
waits to emit the given source until after data has been gathered
Customizing behavior for the language of the source code you're generating
Many of Cecil's defaults can be customized by creating a subclass of Cecil::Code
and overriding methods to customize syntax and behavior of:
- placeholder syntax
- indentation
- auto-closing brackets
Currently, Cecil comes with:
Cecil::Code
for generic codeCecil::Lang::TypeScript
for JavaScript and TypeScriptCecil::Lang::Rust
for Rust
Auto-closing brackets
Customize which opening brackets are auto-closed by overriding
Cecil::Code#block_ending_pairs
in a subclass.
When nesting code blocks with #[] { ... }
, open brackets at the end of the string get closed automatically.
For example, notice how we don't have to manually provide a closing }
in the following:
`$var = {`[var: "user"] do
`id: 42`
end
becomes
user = {
id: 42
}
Multiple brackets
Every consecutive closing bracket at the end of the string gets closed. E.g.
`$var = [{(`[var: "user"] do
`id: 42`
end
becomes
user = ([{
id: 42
}])
Currently, the algorithm is simplistic, so open brackets that aren't at the end of the string will not get closed.
In this example, the (
in test(
needs to be closed manually:
`test("getter $fn", () => {`[fn: 'getUsername'] do
`assert(false)`
end << `)`
test("getter getUsername", () => {
assert(false)
})
Placeholder syntax
Default placeholder rules:
- start with
$
-- e.g.$foo
- named can contain alpha-numeric and underscore characters-- e.g.
$foo_bar123
- names can optionally be surrounded by brackets -- e.g
${my_placeholder}
,$[my_placeholder]
,$<my_placeholder>
, or$(my_placeholder)
Surrounding with brackets can be useful to separate a placeholder from subsequent characters that would otherwise get parsed as a placeholder.
E.g. function ${fn}Sync()
-- without curly brackets $fnSync
would be the placeholder.
Customize placeholder syntax by subclassing Cecil::Code
and overriding placeholder-related methods.
Helper methods
If you use your generator frequently it can be helpful to define reusable helper methods on a subclass of Cecil::Code
.
For example, the Cecil::Lang::TypeScript
]{Lang::TypeScript subclass defines several [helper methods} for generating TypeScript code.
Installation
From your shell:
bundle add cecil
In your Gemfile like:
gem 'cecil'
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and the created tag, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/nicholaides/cecil.
License
The gem is available as open source under the terms of the MIT License.