Part of the Lasagna Pattern toolbox.
Core pattern DSL for declarative matching and transformation of Clojure data structures. Pure data matching — no I/O, no storage, no HTTP.
Extracting data from nested structures typically requires manual traversal code that's tedious to write and hard to maintain. This library provides a pattern language where you describe what you want, not how to get it:
- Declarative — Patterns mirror the shape of your data
- Variable binding — Extract values into named bindings
- Constraints — Filter matches with predicates and defaults
- Lazy compatible — Works with any
ILookupimplementation (databases, APIs, collections) - Schema validation — Optional compile-time validation with Malli
- Cross-platform —
.cljcthroughout, runs on CLJ and CLJS
;; deps.edn
{:deps {sg.flybot/lasagna-pattern {:mvn/version "RELEASE"}}}
;; Leiningen
[sg.flybot/lasagna-pattern "RELEASE"]Only hard dependency is org.clojure/clojure. Optional deps: org.babashka/sci (sandboxed eval, required for CLJS), metosin/malli (schema validation).
(require '[sg.flybot.pullable :refer [match-fn rule apply-rules failure?]])
;; Create a matcher — returns bindings on match
(def m (match-fn '{:name ?n :age ?a} {:name ?n :age ?a}))
(m {:name "Alice" :age 30})
;=> {:name "Alice", :age 30}
;; Just extract a value
((match-fn '{:name ?n} ?n) {:name "Alice"})
;=> "Alice"
;; Use $ to access the original matched value
((match-fn '{:a ?x} (assoc $ :sum ?x)) {:a 1 :b 2})
;=> {:a 1 :b 2 :sum 1};; :when — predicate constraint
(def adult (match-fn '{:age (?a :when #(>= % 18))} ?a))
(adult {:age 25}) ;=> 25
(adult {:age 10}) ;=> MatchFailure
;; :default — fallback value
((match-fn '{:name (?n :default "Anonymous")} ?n) {})
;=> "Anonymous";; Literal values — exact equality check
((match-fn '{:type :user} $) {:type :user :name "Alice"})
;=> {:type :user :name "Alice"}
;; Functions — predicate check
((match-fn '{:age even?} $) {:age 30}) ;=> {:age 30}
((match-fn '{:age even?} $) {:age 31}) ;=> MatchFailure
;; Sets — membership check
((match-fn '{:status #{:active :pending}} $) {:status :active})
;=> {:status :active}
;; Regex — string pattern match
((match-fn '{:phone #"(\d{3})-(\d{4})"} $) {:phone "555-1234"})
;=> {:phone "555-1234"};; Fixed-length destructure
((match-fn '[?a ?b] [?a ?b]) [1 2])
;=> [1 2]
;; Head + rest (zero or more)
((match-fn '[?first ?rest*] {:first ?first :rest ?rest}) [1 2 3 4])
;=> {:first 1, :rest (2 3 4)}
;; One or more — fails on empty
((match-fn '[?items+] ?items) [1 2 3]) ;=> (1 2 3)
((match-fn '[?items+] ?items) []) ;=> MatchFailure
;; Optional element — nil when absent
((match-fn '[?a ?b?] [?a ?b]) [1]) ;=> [1 nil]
((match-fn '[?a ?b?] [?a ?b]) [1 2]) ;=> [1 2]
;; Wildcard — skip without binding
((match-fn '[?_ ?second] ?second) [1 2]) ;=> 2
;; Unification — same var must match equal values
((match-fn '[?x ?x] ?x) [1 1]) ;=> 1
((match-fn '[?x ?x] ?x) [1 2]) ;=> MatchFailure;; Pattern → template transformation
(def double-to-add (rule '(* 2 ?x) '(+ ?x ?x)))
(double-to-add '(* 2 5)) ;=> (+ 5 5)
(double-to-add '(* 3 5)) ;=> nil (no match)
;; Apply rules recursively (bottom-up)
(def simplify-add-0 (rule '(+ 0 ?x) ?x))
(apply-rules [double-to-add simplify-add-0] '(+ 0 (* 2 y)))
;=> (+ y y)(let [result ((match-fn '{:x ?x} ?x) "not a map")]
(when (failure? result)
{:reason (:reason result)
:path (:path result)
:value (:value result)}))
;=> {:reason "expected map", :path [], :value "not a map"}| Pattern | Description |
|---|---|
?x |
Bind value to x |
?_ |
Wildcard (match anything, no binding) |
?x? |
Optional (0-1 elements, sequences only) |
?x* |
Zero or more (lazy) |
?x+ |
One or more (lazy) |
?x*! |
Zero or more (greedy) |
?x+! |
One or more (greedy) |
| Pattern | Description |
|---|---|
(?x :when pred) |
Constrained match |
(?x :default val) |
Default on failure |
(?x :when pred :default val) |
Both chained (default first, then predicate) |
(?x* :take N) |
Take up to N elements. Pair with ?_* for rest. |
(?_ :skip N) |
Skip exactly N elements |
| Pattern | Description |
|---|---|
{} |
Map pattern (maps + ILookup) |
[] |
Sequence pattern (any seqable) |
{{:id 1} ?result} |
Indexed lookup (ILookup) |
$ |
Original input (in match-fn body) |
| Value in pattern | Behavior |
|---|---|
?x |
Variable binding |
:keyword / 42 / "str" |
Exact equality check |
even? (function) |
Predicate — (even? val) |
#{:a :b} (set) |
Membership — (contains? #{:a :b} val) |
#"regex" |
Regex match on string values |
Map patterns work with both standard Clojure maps and any ILookup implementation:
;; Standard map — preserves unmatched keys
((match-fn '{:a ?x} $) {:a 1 :b 2 :c 3})
;=> {:a 1 :b 2 :c 3} ; :b and :c preserved
;; ILookup — only returns matched keys (can't enumerate)
;; Used for lazy data sources (databases, collections)
;; Indexed lookup with non-keyword keys
{{:id 1} ?user} ; lookup by map query
{[0 1] ?cell} ; lookup by vector keyOptional compile-time validation using :schema option:
;; With Malli schemas (require sg.flybot.pullable.malli first)
(require '[sg.flybot.pullable.malli])
(require '[malli.core :as m])
((match-fn '{:name ?n} ?n {:schema (m/schema [:map [:name :string]])})
{:name "alice"})
;=> "alice"
;; Indexed lookup requires :ilookup annotation
(def api-schema
(m/schema [:map [:users [:vector {:ilookup true}
[:map [:id :int] [:name :string]]]]]))
;; Now both patterns are valid:
'{:users ?all} ; LIST — always valid
'{:users {{:id 1} ?user}} ; GET — requires :ilookup trueSchemas also act as visibility control — only declared keys are accessible to patterns. Undeclared keys become effectively private.
| Function | Kind | Signature | Description |
|---|---|---|---|
match-fn |
Macro | [pattern body] or [pattern body opts] |
Create matcher function with variable bindings |
rule |
Macro | [pattern template] |
Create pattern → template transformation |
apply-rules |
Fn | [rules data] |
Apply rules recursively (bottom-up postwalk) |
failure? |
Fn | [x] |
Predicate for MatchFailure records |
| Option | Description |
|---|---|
:schema |
Malli or built-in schema for compile-time validation |
:rules |
Custom rewrite rules (prepended to defaults) |
:only |
Replace all default rules with these |
:resolve |
Custom symbol resolver (fn [sym]) |
:eval-fn |
Custom form evaluator (fn [form]) |
| Function | Signature | Description |
|---|---|---|
register-var-option! |
[key handler-fn] |
Register custom variable option (like :when) |
register-schema-rule! |
[rule-fn] |
Register custom schema type inference |
get-schema-info |
[schema] |
Query schema type and structure |
bb test pattern # Run full test suite (Kaocha + RCT)
bb rct pattern # Run RCT tests only
bb dev pattern # Start REPLSee CLAUDE.md for architecture, internals, and extension points. See doc/performance-analysis.md for complexity characteristics.