diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd431fb..84701cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+# 1.2.1
+
+## Added
+
+ADDED `command-quote-spec` in `commando.commands.builtin` — a new command type `:commando/quote` (string form `"commando-quote"`) that brings Lisp-style quasiquote semantics to instructions. A `:commando/quote` body is treated as **inert data**: commands inside it are NOT executed and the scanner stops descending — *except* inside `:commando/unquote` holes (string form `"commando-unquote"`), which ARE executed and whose results are substituted back into the body.
+- `:commando/unquote` is pure syntax recognized only inside a quote — it is not a registered command and has no special meaning outside a quote.
+- A nested `:commando/quote` stays fully inert (its wrapper is left untouched), so quotes can be nested safely.
+- `:commando/from` and other commands inside an `:commando/unquote` resolve against the full instruction, so unquote holes can reference values outside the quote.
+- A `:commando/from` pointing *at* a quote node receives the expanded (unquoted) body.
+
+ADDED dependency mode `:quote` in `commando.impl.dependency` — internal mode used by `command-quote-spec`. Dependencies are collected from the quote sub-trie but are only visible through `:finding-commands-unquote-keys`; the scanner halts at `:finding-commands-skip-keys` (nested quotes). Supported public modes remain `:point`, `:all-inside`, `:none`.
+
# 1.2.0
## Breaking Changes
diff --git a/README.md b/README.md
index 90d2ac5..b3dec86 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://clojars.org/org.clojars.funkcjonariusze/commando)
[](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml)
-[](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.2.0)
+[](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.2.1)
**Commando** is a flexible Clojure/ClojureScript library for building data-driven DSLs.
@@ -21,6 +21,7 @@
- [command-mutation-spec](#command-mutation-spec)
- [command-macro-spec](#command-macro-spec)
- [command-context-spec](#command-context-spec)
+ - [command-quote-spec](#command-quote-spec)
- [Drivers (Post-Processing)](#drivers-post-processing)
- [Built-in Drivers](#built-in-drivers)
- [Pipeline](#pipeline)
@@ -40,10 +41,10 @@
```clojure
;; deps.edn with git
-{org.clojars.funkcjonariusze/commando {:mvn/version "1.2.0"}}
+{org.clojars.funkcjonariusze/commando {:mvn/version "1.2.1"}}
;; leiningen
-[org.clojars.funkcjonariusze/commando "1.2.0"]
+[org.clojars.funkcjonariusze/commando "1.2.1"]
```
## Quick Start
@@ -401,6 +402,37 @@ Injects external reference data (dictionaries, config, feature flags) into instr
Missing path returns `nil`; use `:default` for an explicit fallback. Use `:=>` driver for post-processing the resolved value, same as in `:commando/from`. String-key form (`"commando-context"`, `"default"`, `"=>"`) is available for JSON compatibility.
+#### command-quote-spec
+
+**Lisp-style quasiquote** for instructions. A `:commando/quote` body is treated as **inert data** — commands inside it are not executed and the scanner stops descending — *except* inside `:commando/unquote` holes, which *are* executed and spliced back into the body.
+
+```
+ {:commando/quote ◄── "everything below is data..."
+ {:kept {:commando/from [:x]} ◄── ...left untouched
+ :filled {:commando/unquote CMD}}} ◄── "...except here — run CMD, paste result"
+```
+
+Reach for it when an instruction must *carry* another instruction as payload instead of evaluating it now: templates, examples, or config forwarded to a later `execute`.
+
+```clojure
+(commando/execute
+ [commands-builtin/command-quote-spec
+ commands-builtin/command-fn-spec]
+ {:tmpl {:commando/quote
+ {:kept {:commando/from [:NO :SUCH :PATH]} ; inert — returned verbatim
+ :filled {:commando/unquote
+ {:commando/fn (constantly 42)}}}}}) ; hole — evaluated
+;; => {:tmpl {:kept {:commando/from [:NO :SUCH :PATH]}, :filled 42}}
+```
+
+Notes:
+- A command inside an `:commando/unquote` resolves against the *whole* instruction, so holes can reference values outside the quote.
+- A nested `:commando/quote` (and its wrapper) stays fully inert, so quotes nest safely.
+- `:commando/unquote` is **pure syntax**, not a registered command — it only means something inside a quote.
+- String-key forms `"commando-quote"` / `"commando-unquote"` are supported for JSON compatibility.
+
+See [`examples/walkthrough.clj`](./examples/walkthrough.clj) for a step-by-step tour.
+
### Drivers (Post-Processing)
Drivers provide a **data-driven** way to post-process command results. After a command's `:apply` function produces a raw result, the driver transforms it before the value is placed back into the instruction.
diff --git a/examples/walkthrough.clj b/examples/walkthrough.clj
index 4b2b272..23db058 100644
--- a/examples/walkthrough.clj
+++ b/examples/walkthrough.clj
@@ -80,7 +80,7 @@
;; │ 3. BUILTIN COMMANDS │
;; └─────────────────────────────────────────────────────┘
;;
-;; Commando ships with six commands. Each one is a
+;; Commando ships with a handful of commands. Each one is a
;; CommandMapSpec — a config map that describes how to
;; recognize, validate, and execute a command type.
@@ -263,6 +263,57 @@
:b {:commando/macro :double :value 21}}))
;; => {:a 10, :b 42}
+;; ─────────────────────────────────────────────────────
+;; :commando/quote + :commando/unquote
+;; ─────────────────────────────────────────────────────
+;;
+;; Lisp-style quasiquote. A `:commando/quote` body is INERT
+;; data — commands inside it are NOT executed and the scanner
+;; stops descending — EXCEPT inside `:commando/unquote` holes,
+;; which ARE executed and spliced back into the body.
+;;
+;; {:commando/quote
+;; {:kept {:commando/from [:x]} ← left untouched
+;; :filled {:commando/unquote CMD}}} ← run CMD, paste result
+;;
+;; Use it to CARRY an instruction as payload (a template,
+;; an example, config forwarded to a later `execute`) instead
+;; of evaluating it right now.
+
+(:instruction
+ (commando/execute
+ [builtin/command-quote-spec
+ builtin/command-fn-spec]
+ {:tmpl {:commando/quote
+ {:kept {:commando/from [:NO :SUCH :PATH]} ; inert → verbatim
+ :filled {:commando/unquote
+ {:commando/fn (constantly 42)}}}}})) ; hole → evaluated
+;; => {:tmpl {:kept {:commando/from [:NO :SUCH :PATH]}, :filled 42}}
+;;
+;; NOTE: the broken `:commando/from` never runs — it's data.
+;; Only the `:commando/unquote` hole is evaluated.
+
+;; A hole resolves against the WHOLE instruction, so it can
+;; reach values that live outside the quote:
+
+(:instruction
+ (commando/execute
+ [builtin/command-quote-spec
+ builtin/command-fn-spec
+ builtin/command-from-spec]
+ {:x {:commando/fn (constantly 10)}
+ :tmpl {:commando/quote
+ {:inner {:commando/from [:x]} ; inert — stays a command
+ :computed {:commando/unquote
+ {:commando/from [:x]}}}}})) ; evaluated — sees :x
+;; => {:x 10,
+;; :tmpl {:inner {:commando/from [:x]}, :computed 10}}
+;;
+;; NOTE: `:commando/unquote` is pure syntax — it only means
+;; something inside a quote, and is never registered as
+;; a command on its own. A nested `:commando/quote` stays
+;; fully inert, so quotes nest safely.
+
;; ┌─────────────────────────────────────────────────────┐
;; │ 4. DRIVERS (POST-PROCESSING) │
@@ -462,9 +513,10 @@
;;
;; You now know the core building blocks:
;; • Instructions — maps with data + commands
-;; • Six builtin commands: `:commando/from`, `:commando/fn`,
+;; • Builtin commands: `:commando/from`, `:commando/fn`,
;; `:commando/apply`, `:commando/context`,
-;; `:commando/mutation`, `:commando/macro`
+;; `:commando/mutation`, `:commando/macro`,
+;; `:commando/quote` (+ `:commando/unquote`)
;; • Drivers (`:=>`) for post-processing + pipelines
;; • Custom commands via CommandMapSpec
;;
diff --git a/pom.xml b/pom.xml
index 1f50b6b..eeaf94b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
jar
org.clojars.funkcjonariusze
commando
- 1.2.0
+ 1.2.1
commando
@@ -42,6 +42,6 @@
https://github.com/funkcjonariusze/commando
scm:git:git://github.com/funkcjonariusze/commando.git
scm:git:ssh://git@github.com:funkcjonariusze/commando.git
- 1.2.0
+ 1.2.1
diff --git a/src/commando/commands/builtin.cljc b/src/commando/commands/builtin.cljc
index 74d22c1..054e034 100644
--- a/src/commando/commands/builtin.cljc
+++ b/src/commando/commands/builtin.cljc
@@ -382,6 +382,75 @@
(command-mutation (get m "commando-mutation") (dissoc m "commando-mutation"))))
:dependencies {:mode :all-inside}})
+;; == Quote / Unquote =====================================
+
+(def ^:private -quote-unquote-keys [:commando/unquote "commando-unquote"])
+(def ^:private -quote-skip-keys [:commando/quote "commando-quote"])
+
+(defn ^:private -unwrap-unquotes
+ "Walks an already-resolved quote body and replaces every unquote hole
+ {:commando/unquote X} (or its string form) with the inner value X.
+ Stops at a nested quote (skip-keys) — that subtree stays inert."
+ [node]
+ (cond
+ (not (coll? node)) node
+ (map? node)
+ (cond
+ (some #(contains? node %) -quote-skip-keys) node
+ (some #(contains? node %) -quote-unquote-keys)
+ (get node (some #(when (contains? node %) %) -quote-unquote-keys))
+ :else (reduce-kv (fn [acc k v] (assoc acc k (-unwrap-unquotes v)))
+ (empty node) node))
+ (vector? node) (mapv -unwrap-unquotes node)
+ :else node))
+
+(def ^{:doc "
+ Description
+ command-quote-spec - marks a subtree as inert data: commands inside a
+ `:commando/quote` body are NOT executed (the scanner stops descending),
+ EXCEPT inside `:commando/unquote` holes, which ARE executed and whose
+ results are substituted back into the body (Lisp quasiquote semantics).
+
+ `:commando/unquote` is pure syntax recognized only inside a quote — it is
+ not a registered command. A nested `:commando/quote` is fully inert and
+ left untouched (including its wrapper). Outside any quote, `:commando/unquote`
+ has no special meaning: the key stays as plain data and whatever is inside
+ runs as an ordinary nested command.
+
+ String forms `\"commando-quote\"` / `\"commando-unquote\"` are supported for
+ JSON-compatible instructions.
+
+ Example
+ (:instruction
+ (commando/execute
+ [command-quote-spec command-fn-spec]
+ {:q {:commando/quote
+ {:value 42
+ :computed {:commando/unquote {:commando/fn (constantly 42)}}}}}))
+ ;; => {:q {:value 42 :computed 42}}
+
+ (:instruction
+ (commando/execute
+ [command-quote-spec command-from-spec]
+ {:result {:commando/quote {:commando/from [:NO :SUCH :PATH]}}}))
+ ;; => {:result {:commando/from [:NO :SUCH :PATH]}} ; body untouched
+
+ See Also
+ `commando.core/execute`
+ `commando.commands.builtin/command-fn-spec`"}
+ command-quote-spec
+ {:type :commando/quote
+ :recognize-fn #(and (map? %) (or (contains? % :commando/quote)
+ (contains? % "commando-quote")))
+ :apply (fn [_ _ m]
+ (-unwrap-unquotes
+ (if (contains? m :commando/quote)
+ (get m :commando/quote)
+ (get m "commando-quote"))))
+ :dependencies {:mode :quote
+ :finding-commands-unquote-keys -quote-unquote-keys
+ :finding-commands-skip-keys -quote-skip-keys}})
+
;; == Macro ================================================
(defmulti command-macro (fn [tx-type _data] tx-type))
diff --git a/src/commando/impl/command_map.cljc b/src/commando/impl/command_map.cljc
index 055bd34..c939c19 100644
--- a/src/commando/impl/command_map.cljc
+++ b/src/commando/impl/command_map.cljc
@@ -108,11 +108,14 @@
[:apply fn?]
[:dependencies
[:merge
- [:map [:mode [:enum :none :all-inside :point :custom]]]
+ [:map [:mode [:enum :none :all-inside :point :quote]]]
[:multi {:dispatch :mode}
[:none [:map]]
[:all-inside [:map]]
- [:point [:map [:point-key [:+ [:or :keyword :string]]]]]]]]]
+ [:point [:map [:point-key [:+ [:or :keyword :string]]]]]
+ [:quote [:map
+ [:finding-commands-unquote-keys [:+ [:or :keyword :string]]]
+ [:finding-commands-skip-keys [:+ [:or :keyword :string]]]]]]]]]
{:registry (merge (malli/default-schemas) (malli-util/schemas))}))
(defn validate-command-spec
diff --git a/src/commando/impl/dependency.cljc b/src/commando/impl/dependency.cljc
index e552d9f..161c239 100644
--- a/src/commando/impl/dependency.cljc
+++ b/src/commando/impl/dependency.cljc
@@ -14,7 +14,8 @@
if it Map - the values, if it Vector - elements of vectors
- :point - depends on command(s) at a specific path, defined by :point-key
setting key. :point collect only one depedency - the only one it refering.
- - :none - no dependencies (not implemented, returns empty set by default)"
+ - :none - no dependencies (not implemented, returns empty set by default)
+ - :quote - internal dependency visible only under unquote-keys"
(fn [_command-path-obj _instruction _path-trie dependency-type] dependency-type))
;; -- Default --
@@ -31,6 +32,21 @@
;; -- All Inside --
+(defmethod find-command-dependencies :quote
+ [command-path-obj _instruction path-trie _type]
+ (let [command-path (cm/command-path command-path-obj)
+ sub-trie (get-in path-trie command-path)]
+ (letfn [(collect [acc node]
+ (reduce-kv (fn [a k v]
+ (cond
+ ;; `=` (not `identical?`): keyword identity is unreliable in cljs,
+ ;; which would silently drop quote->hole dependency edges.
+ (= k :commando.impl.pathtrie/command) (conj a v)
+ (map? v) (collect a v)
+ :else a))
+ acc node))]
+ (collect #{} (dissoc sub-trie :commando.impl.pathtrie/command)))))
+
(defmethod find-command-dependencies :all-inside
[command-path-obj _instruction path-trie _type]
;; Direct reduce-kv instead of dissoc+vals+keep+set chain.
diff --git a/src/commando/impl/finding_commands.cljc b/src/commando/impl/finding_commands.cljc
index 8d4d8d8..d12284b 100644
--- a/src/commando/impl/finding_commands.cljc
+++ b/src/commando/impl/finding_commands.cljc
@@ -30,6 +30,26 @@
(defmethod enqueue-command-children! :all-inside [queue _command-spec value current-path]
(enqueue-coll-children! queue value current-path))
+(defmethod enqueue-command-children! :quote
+ [queue command-spec value current-path]
+ (let [{:keys [finding-commands-unquote-keys
+ finding-commands-skip-keys]} (:dependencies command-spec)
+ marked? (fn [m ks] (and (map? m) (some #(contains? m %) ks)))
+ marked-key (fn [m ks] (some #(when (contains? m %) %) ks))]
+ (loop [stack [[current-path value]] q queue]
+ (if-let [[path node] (peek stack)]
+ (cond
+ (and (not= path current-path) (marked? node finding-commands-unquote-keys))
+ (recur (pop stack) (conj! q (conj path (marked-key node finding-commands-unquote-keys))))
+ (and (not= path current-path) (marked? node finding-commands-skip-keys))
+ (recur (pop stack) q)
+ (map? node)
+ (recur (into (pop stack) (map (fn [[k v]] [(conj path k) v])) node) q)
+ (coll? node)
+ (recur (into (pop stack) (map-indexed (fn [i v] [(conj path i) v])) node) q)
+ :else (recur (pop stack) q))
+ q))))
+
(defn command?
[{:keys [recognize-fn]
:as command-spec}
@@ -37,13 +57,13 @@
(try (recognize-fn value)
(catch #?(:cljs :default
:clj Exception)
- e
- (throw (ex-info (str utils/exception-message-header
- "Failed while running recognize command on: "
- (:type command-spec))
- {:command-spec command-spec
- :value value
- :error (utils/serialize-exception e)})))))
+ e
+ (throw (ex-info (str utils/exception-message-header
+ "Failed while running recognize command on: "
+ (:type command-spec))
+ {:command-spec command-spec
+ :value value
+ :error (utils/serialize-exception e)})))))
(defn command-valid?
[{:keys [validate-params-fn]
diff --git a/test/unit/commando/commands/builtin_test.cljc b/test/unit/commando/commands/builtin_test.cljc
index 4fba0bd..1b127c2 100644
--- a/test/unit/commando/commands/builtin_test.cljc
+++ b/test/unit/commando/commands/builtin_test.cljc
@@ -591,3 +591,98 @@
(is (commando/ok? result) "Non-map command executes without error")
(is (= "hello" (get-in (:instruction result) [:calm])) "Non-command values unchanged")
(is (= "HELLO!" (get-in (:instruction result) [:excited])) "String command applied correctly"))))
+
+
+;; ===========================
+;; QUOTING
+;; ===========================
+
+(deftest quote-stops-scanner
+ (is (= {:result {:commando/from [:NO :SUCH :PATH]}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-from-spec]
+ {:result {:commando/quote {:commando/from [:NO :SUCH :PATH]}}})))
+ "body with a broken :commando/from under quote is returned untouched"))
+
+(deftest unquote-executes-and-substitutes
+ (is (= {:q {:value 42 :computed 42}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-fn-spec]
+ {:q {:commando/quote
+ {:value 42
+ :computed {:commando/unquote
+ {:commando/fn (constantly 42)}}}}})))
+ "the unquote node is executed and its result replaces the node in the body"))
+
+(deftest unquote-body-can-reference-outside
+ (is (= {:x 10
+ :q {:inner {:commando/from [:x]} :computed 10}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-from-spec
+ command-builtin/command-fn-spec]
+ {:x {:commando/fn (constantly 10)}
+ :q {:commando/quote
+ {:inner {:commando/from [:x]}
+ :computed {:commando/unquote
+ {:commando/from [:x]}}}}})))
+ ":commando/from inside an unquote body sees the outer value"))
+
+(deftest nested-quote-is-inert
+ (is (= {:outer {:inner-quote {:commando/quote {:commando/from [:NO :SUCH]}}}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-from-spec]
+ {:outer {:commando/quote
+ {:inner-quote {:commando/quote {:commando/from [:NO :SUCH]}}}}})))
+ "a nested quote stops the scanner and stays in the body together with its wrapper"))
+
+(deftest string-key-forms
+ (is (= {"result" {"static" 1 "computed" 99}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-fn-spec]
+ {"result" {"commando-quote"
+ {"static" 1
+ "computed" {"commando-unquote"
+ {:commando/fn (constantly 99)}}}}})))
+ "string-key forms of quote and unquote work end to end"))
+
+(deftest from-on-quote-node-sees-resolved-body
+ (is (= {:q {:value 5}
+ :ref {:value 5}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-from-spec]
+ {:q {:commando/quote {:value 5}}
+ :ref {:commando/from [:q]}})))
+ ":commando/from on a quote node returns the expanded body"))
+
+(deftest multiple-unquotes-in-one-quote
+ (is (= {:q {:a 1 :b 2 :static "x"}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-fn-spec]
+ {:q {:commando/quote
+ {:a {:commando/unquote {:commando/fn (constantly 1)}}
+ :b {:commando/unquote {:commando/fn (constantly 2)}}
+ :static "x"}}})))
+ "multiple unquotes in one quote are executed independently"))
+
+(deftest unquote-outside-quote-is-data
+ (is (= {:standalone {:commando/unquote 42}}
+ (:instruction
+ (commando/execute
+ [command-builtin/command-quote-spec
+ command-builtin/command-fn-spec]
+ {:standalone {:commando/unquote {:commando/fn (constantly 42)}}})))
+ "outside a quote, unquote is not expanded: the key stays as data and the nested command runs as usual"))