Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 55 additions & 3 deletions examples/walkthrough.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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) │
Expand Down Expand Up @@ -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
;;
Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<packaging>jar</packaging>
<groupId>org.clojars.funkcjonariusze</groupId>
<artifactId>commando</artifactId>
<version>1.2.0</version>
<version>1.2.1</version>
<name>commando</name>
<dependencies>
<dependency>
Expand Down Expand Up @@ -42,6 +42,6 @@
<url>https://github.com/funkcjonariusze/commando</url>
<connection>scm:git:git://github.com/funkcjonariusze/commando.git</connection>
<developerConnection>scm:git:ssh://[email protected]:funkcjonariusze/commando.git</developerConnection>
<tag>1.2.0</tag>
<tag>1.2.1</tag>
</scm>
</project>
69 changes: 69 additions & 0 deletions src/commando/commands/builtin.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 5 additions & 2 deletions src/commando/impl/command_map.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/commando/impl/dependency.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand All @@ -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.
Expand Down
34 changes: 27 additions & 7 deletions src/commando/impl/finding_commands.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,40 @@
(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}
value]
(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]
Expand Down
Loading
Loading