From 1273a54a54d89a604653fb6aa0a1117aa3dd884b Mon Sep 17 00:00:00 2001 From: SerhiiRI Date: Mon, 15 Jun 2026 18:47:33 +0300 Subject: [PATCH] Quoting/Unquoting lispy-like macro feature for instruction - ADDED New builtin command-quote-spec support :commando/quote + :commando/unquote behavior in one specyfication - ADDED dependency mode :quote. --- CHANGELOG.md | 12 +++ README.md | 38 +++++++- examples/walkthrough.clj | 58 ++++++++++- pom.xml | 4 +- src/commando/commands/builtin.cljc | 69 ++++++++++++++ src/commando/impl/command_map.cljc | 7 +- src/commando/impl/dependency.cljc | 18 +++- src/commando/impl/finding_commands.cljc | 34 +++++-- test/unit/commando/commands/builtin_test.cljc | 95 +++++++++++++++++++ 9 files changed, 317 insertions(+), 18 deletions(-) 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 @@ [![Clojars Project](https://img.shields.io/clojars/v/org.clojars.funkcjonariusze/commando.svg)](https://clojars.org/org.clojars.funkcjonariusze/commando) [![Run tests](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml/badge.svg)](https://github.com/funkcjonariusze/commando/actions/workflows/unit_test.yml) -[![cljdoc badge](https://cljdoc.org/badge/org.clojars.funkcjonariusze/commando)](https://cljdoc.org/d/org.clojars.funkcjonariusze/commando/1.2.0) +[![cljdoc badge](https://cljdoc.org/badge/org.clojars.funkcjonariusze/commando)](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"))