This article illustrates how data specification can be used to validate and parse DSLs. We’ll use Clojure to create data-driven DSL for a subset of CSS, and clojure.spec library to define and parse the syntax.
clojure.spec is a specification library which provides tools (predicates and composition rules) to describe how data should look like, check if it conforms to that specification and provide an explanation of the cause in case of failure.
(s/def ::name string?)
(s/def ::age pos-int?)
(s/def ::profile (s/keys :req [::name ::age]))
(s/valid? ::profile {::name "Roman" ::age 25.9})
;; false
(s/explain ::profile {::name "Roman" ::age 25.9})
In: [:specs.profile/age]
val: 25.9 fails spec: :specs.profile/age
at: [:specs.profile/age] predicate: pos-int?
When conforming data the returning value is destructured according to the spec. This is very useful for parsing DSLs, because the output can clearly describe the structure of the syntax. Think of it as a way to produce an AST, which is much more useful for analysis and compilation. We’ll see this later.
As an example we’ll create a syntax for CSS Transforms value.
[[:translate-x 120 :px]
[:translate-y 150 :px]]
;; -> translateX(120px), translateY(150px)
[[:translate 120 :px]]
;; -> translate(120px)
[[:translate 120 :px 150 :px]]
;; -> translate(120px, 150px)
Let’s start by looking at W3C CSS Transforms specification first. The spec clearly defines that the value of transform
property is either none
or <transform-list>
.
This translates into the code in a straightforward way:
(s/def ::transform ;; transform
(s/alt ;; is either
:something ::transform-list ;; <transform-list>
:nothing #{:none})) ;; or `none`
The spec defines <transform-list>
as one or more <transform-function>
tokens.
In code this can be represented using clojure.spec’s regex operators which operate on sequences.
(s/def ::transform-list ;; <transform-list>
(s/cat ;; is a sequence
:fns (s/+ ::transform-function)))
;; of one or more <transform-function>
<transform-function>
is one of those CSS transform functions which you may be familiar with, e.g. translateX(x)
, translate(x, y)
.
(s/def ::transform-function ;; <transform-function>
(s/or ;; is either
:translate-x ::translate-x ;; translateX
:translate-y ::translate-y ;; translateY
:translate ::translate)) ;; or translate
From the signature of these functions we can see that they accept a value of type <length-percentage>
, which is either <length>
(px
, em
, etc.) or <percentage>
(%
) type units.
(s/def ::length-percentage
(s/alt
:length ::length
:percentage ::percentage))
According to the syntax which we’ve specified in the beginning a value with a unit is a sequence of two elements: number
and a keyword
which represents the type of the unit.
(s/def ::length
(s/cat
:value number?
:unit #{:px}))
<percentage>
has the same syntax, just with different type :%
(s/def ::percentage
(s/cat
:value number?
:unit #{:%}))
Let’s see how destructuring with spec works for ::length-percentage
specification.
(s/conform ::length-percentage [100 :px])
;; [:length {:value 100, :unit :px}]
The returned value here is based on the spec that we’ve provided and the missing spots are filled in with values that are parsed out of the provided data. This representation is basically an AST.
Now that we have a spec for function parameters, let’s describe how the syntax of those functions look like.
(s/def ::translate-x ;; translate-x function
(s/cat ;; is a sequence
:function #{:translate-x} ;; of :translate-x keyword
:value ::length-percentage))
;; and a nested sequence of ::length-percentage
;; translate-y is similar
(s/def ::translate-y
(s/cat
:function #{:translate-y}
:value ::length-percentage))
transform
function is more interesting, because it has two parameters and the second one is optional.
(s/def ::translate ;; translate fn
(s/cat ;; is a sequence
:function #{:translate} ;; of :translate keyword
:value
(s/cat ;; sequence
:x ::length-percentage ;; of ::length-percentage
:y (s/? ::length-percentage)))))
;; and optional second value ::length-percentage
Finally let’s test the entire thing.
(s/conform ::transform [[:translate -190 :px 80 :px]])
[:something
{:fns
[[:translate
{:function :translate,
:value
{:x [:length {:value -190, :unit :px}],
:y [:length {:value 80, :unit :px}]}}]]}]
Now the compilation process goes into traversing the tree and translating tokens into a proper CSS format.
Also when conforming fails, meaning that there’s an error somewhere in the syntax, we can ask for a reason, why it is failed, in a form of data.
(s/conform ::transform [[:translate -190 :px 80 :p]])
;; :clojure.spec.alpha/invalid
(s/explain-data ::transform [[:translate -190 :px 80 :p]])
{:problems
'({:path [:something :fns :translate :value :y :length :unit],
:pred #{:px},
:val :p,
:via
[:specs.css/transform
:specs.css/transform
:specs.css/transform-list
:specs.css/transform-function
:specs.css/transform-function
:specs.css/translate],
:in [0 4]}),
:spec :specs.css/transform,
:value [[:translate -190 :px 80 :p]]}
We can see that the value :p
in [[:translate -190 :px 80 :p]]
failed predicate #{:px}
of the specification :specs.css/translate
. As you might guessed, this data can be used to report an error in a human-readable format.
For more real-world examples see the code that I’ve written for parsing and compiling syntax for CSS Media Queries.
To learn more about clojure.spec check out the rationale and overview.
Top comments (2)
Thank you for this great write up. I've always looked at Spec and found it very interesting but failed at finding out how to use it in any real world scenario.
You have provided some very good insights.
Spec is widely used to parse DSLs and macros. Data validation is another good example. I’ll blog more about it later.