A code generation library for Clojure projects. Write generators that create or modify source files using template substitution and structural code editing via rewrite-clj. Serves the same purpose as Ruby on Rails generators.
It's used by the Donut single-page app framework to allow users to call something like this:
(dg/generate :donut/endpoint {:endpoint-name 'lists
:top 'my-app})Which then:
- Creates the file
src/my_app/backend/endpoints/lists_endpoint.clj - Modifies the file
src/my_app/backend/routes.clj, updating the list of routes to include the routes from the new endpoint
This library does not include any Donut-specific generators; you can use it in your own project to define your own generators that consumers can then use.
Status: alpha
From dev/donut/generate/examples.clj:
(require '[donut.generate :as dg])
(defmethod dg/generator ::endpoint-file
[_ {:keys [top endpoint-name] :as data}]
(let [ns-name (str top ".backend.endpoint." endpoint-name "-endpoint")]
{:points [{:destination {:namespace ns-name
:extension "clj"
:dir "test-generated-files"}
:content {:template "(ns {{ns-name}})"}}]
:data (assoc data :ns-name ns-name)}))
(dg/generate ::endpoint-file {:endpoint-name 'lists
:top 'generate-test})This example creates a file
test-generated/generate_test/backend/endpoint/lists_endpoint.clj. The contents
of the file are:
(ns generate-test.backend.endpoint.lists-endpoint)Generators are defined using the dg/generator multimethod. It receives
a name and user-supplied data, and returns a map with:
| Key | Description |
|---|---|
:points |
a sequence of point maps describing what to write |
:data |
additional data to merge into each point's substitution context |
(defmethod generator :my/component [_ data]
{:data {:top "myapp"}
:points [{:id :name-of-point-for-logging-debugging
:description "describe what the point does, helps with logging"
:destination {:namespace "{{top|ns}}.components.{{component-name}}"
:extension "cljs"}
:data {}
:content {:template "(ns {{top|ns}}.components.{{component-name}})"}}]})
(dg/generate :my/component {:component-name "component.name"})A point is a map describing what to generate and where.
| Key | Description |
|---|---|
:id |
Keyword, used for logging and exception handling |
:description |
String, used for logging |
:destination |
Where to write via :path or :namespace (see below) |
:content |
What to write a :template string or a :form (quoted Clojure form) |
:data |
Local substitution data, merged with generator-level data |
:modify |
If present, performs a targeted edit on an existing file instead of writing a new one |
:destination describes what file to update. You can describe either a file
system path or a namespace.
:path a literal (or templated) file path:
{:destination {:path "src/{{top|file}}/routes.cljc"}}top corresponds to a key in the data map, and |file transforms the value to
match Clojure file naming conventions.
:namespace — converted to a file path automatically:
{:destination {:namespace "{{top|ns}}.backend.routes"
:extension "cljc"
:dir "src"}}
;; => writes to src/{{top|file}}/backend/routes.cljctop corresponds to a key in the data map, and |ns transforms the value to
match Clojure namespace naming conventions.
Both support an optional :dir prefix.
All string values in a point are subject to substitution. Given a :data map, donut.generate builds a substitution map where:
{{key}}is replaced with the value as-is{{key|ns}}converts the value to a Clojure namespace string (/→.,_→-){{key|file}}converts the value to a file path string (.→/,-→_)
;; data: {:top "my_app"}
"{{top}}" => "my_app"
"{{top|ns}}" => "my-app"
"{{top|file}}" => "my_app"
;; data: {:top "my-app.core"}
"{{top|file}}" => "my_app/core"Note that substitution is whitespace-sensitive; {{top}} works but {{ top }} doesn't.
When a point includes a :modify key, donut.generate uses
rewrite-clj
to edit an existing file rather than overwriting it.
The :modify map has two keys:
| key | description |
|---|---|
:path |
navigates to data structure to edit |
:edits |
rewrite-clj edit functions to apply |
:path is a vector of rewrite-clj navigators for navigating to the data
structure you want to edit. The :path vector also allows these kinds of values
for convenience:
- clojure value
- example:
'routes' - behavior: navigates to parent of that value
- example:
donut.generate/prednavigator- example:
(donut.generate/pred vector?) - behavior: navigates to value where pred? returns true. Note that
pred?should be a rewrite-clj predicate. The Clojure predicatesmap?,list?,seq?,set?, andvector?are mapped to their rewrite-clj equivalent
- example:
Example:
(defmethod dg/generator :route [_ data]
{:data data
:points [{:destination {:path "src/myapp/routes.cljc"}
:content {:template "{{route-name}}"}
:modify {:path ['routes (dg/pred vector?)]
:edits [dg/append-child-newline rz/append-child]}}]})Assuming routes.cljc contains this form:
(def routes
[:route-1])When you call generate, this is what happens:
(dg/generate :route {:route-name :boop})
;; updated routes.cljc:
(def routes
[:route-1
:boop])The new entry is indented to align with the existing contents.
When a :modify point targets a .json file, donut.generate edits it with
cheshire instead of rewrite-clj. The file
is parsed into Clojure data, the point's :edits transform the data found at
:path, and the result is written back out as pretty-printed JSON.
The parsed JSON is ordinary Clojure data, so the :edits are plain data
functions. Each one is called as (edit value-at-path content):
| key | description |
|---|---|
:path |
key path into the parsed JSON (update-in-style); an empty path [] edits the whole document |
:edits |
data functions applied to the value at :path, e.g. merge, conj, assoc |
Each edit receives the value from :content. Use a :form to insert Clojure
data, or a :template to insert a JSON string. Both support {{...}}
substitutions.
This example adds a script and a keyword to a package.json:
(defmethod dg/generator :package-json [_ data]
{:data data
:points [{:destination {:path "package.json"}
:modify {:path [:scripts] :edits [merge]}
:content {:form {:test "jest"}}}
{:destination {:path "package.json"}
:modify {:path [:keywords] :edits [conj]}
:content {:form "donut"}}]})Assuming package.json contains:
{
"scripts" : { "build" : "tsc" },
"keywords" : [ "cli" ]
}Running the generator updates it to:
{
"scripts" : {
"build" : "tsc",
"test" : "jest"
},
"keywords" : [ "cli", "donut" ]
};; this generator will create a new file and write its `ns` form
(defmethod donut.generate/generator :my/endpoint [_ data]
{:data data
:points [{:destination {:namespace "{{top|ns}}.backend.endpoint.{{endpoint-name}}"
:extension "clj"
:dir "src"}
:data {} ;; optional point-specific data
:content {:template "(ns {{top|ns}}.backend.endpoint.{{endpoint-name}})"}}]})(donut.generate/generate :my/endpoint {:top "myapp"
:endpoint-name "users"})A new file is created at src/myapp/backend/endpoint/users.clj with content:
(ns myapp.backend.endpoint.users)(generate generator-name data)Runs a named generator with the provided data map. Writes all points produced by the generator.
(defmethod generator :my/generator [name data] ...)Multimethod to register a generator. Return a map of :points and optionally :data.
rewrite-cljfor non-destructive source file modificationclj-kondoresolves namespaced keywords (auto-resolved and aliased) when editing Clojure filescheshireedits JSON files
- try it with babashka
- log results of running generator
- testing helpers
- use
:data-schemafor validation when callinggenerate - handle exceptions by logging