|
| 1 | +;; # Clojure Zippers with Scars à la Huet |
| 2 | +;; _Building scars into clojure zipper library to go up and back down at the previous location._ |
| 3 | +^{:nextjournal.clerk/visibility :hide-ns} |
| 4 | +(ns ^:nextjournal.clerk/no-cache zipper-with-scars |
| 5 | + (:require [clojure.zip :as zip] |
| 6 | + [arrowic.core :as a] |
| 7 | + [nextjournal.clerk :as clerk] |
| 8 | + [nextjournal.clerk.viewer :as v] |
| 9 | + [clojure.string :as str]) |
| 10 | + (:import (clojure.lang Seqable))) |
| 11 | + |
| 12 | +;; I recently stumbled into a parsing scenario where a stream of tokens is folded onto a zipper. |
| 13 | +;; Some of these tokens would just push a new child at the current location while |
| 14 | +;; others would also need to vary the shape some ancestor node. Now the issue with `clojure.zip/up` followed |
| 15 | +;; by a `clojure.zip/down` is that it goes back to _the first_ (or leftmost) of the child nodes. |
| 16 | +;; |
| 17 | +;; Can we build a function to actually _go back_ to the previous lower location |
| 18 | +;; without say, storing some left offset while going up or using a non-core zipper library? |
| 19 | +;; |
| 20 | +;; Mr. Gérard Huet to the rescue here! It turns out |
| 21 | +;; the [original zipper paper](http://gallium.inria.fr/~huet/PUBLIC/zip.pdf) has a memo-version of the up and down functions. Let's use Clerk viewers to illustrate how they work. |
| 22 | +;; |
| 23 | +;; Admittedly, the story with scars is just an excuse to test some animated zipper viewers in Clerk. To that end some machinery follows, unfold at your own peril. |
| 24 | +^{::clerk/visibility :fold ::clerk/viewer :hide-result} |
| 25 | +(do |
| 26 | + (defn loc-seq [zloc] |
| 27 | + ;; this sorts nodes so that children seqs are displayed in the correct order by arrowic |
| 28 | + (try |
| 29 | + (doall |
| 30 | + (concat |
| 31 | + (reverse (take-while some? (next (iterate zip/prev zloc)))) |
| 32 | + (cons zloc |
| 33 | + (take-while (complement zip/end?) (next (iterate zip/next zloc)))))) |
| 34 | + (catch Throwable e |
| 35 | + (throw (ex-info "Cant seq at loc" {:zloc zloc} e))))) |
| 36 | + (def ->name (comp :name zip/node)) |
| 37 | + (defn pnode [zloc] (some-> zloc (get 1) :pnodes peek)) |
| 38 | + (def empty-graph {:vertices #{} :edges []}) |
| 39 | + (defn add-v [g zloc] (update g :vertices conj (->name zloc))) |
| 40 | + (defn add-e [g zloc] |
| 41 | + (let [parent-name (-> zloc pnode :name)] |
| 42 | + (cond-> g parent-name (update :edges conj [parent-name (-> zloc zip/node :name)])))) |
| 43 | + (defn ->graph [zloc] |
| 44 | + (reduce #(-> %1 (add-e %2) (add-v %2)) |
| 45 | + (assoc empty-graph :current? #{(->name zloc)}) |
| 46 | + (loc-seq zloc))) |
| 47 | + (defn insert-vtx [{:keys [current?]} name] |
| 48 | + (doto (a/insert-vertex! name |
| 49 | + :font-color "black" |
| 50 | + :fill-color (if (current? name) "#ec4899" "#a855f7") |
| 51 | + :perimeter-spacing 1 :spacing-bottom 1 :spacing-left 1 |
| 52 | + :font-size 20 :font-style 1) |
| 53 | + (.. getGeometry (setWidth 40)) |
| 54 | + (.. getGeometry (setHeight 40)))) |
| 55 | + (defn ->svg [{:as g :keys [vertices edges]}] |
| 56 | + (a/as-svg |
| 57 | + (a/with-graph (a/create-graph) |
| 58 | + (let [vmap (zipmap vertices (map (partial insert-vtx g) vertices))] |
| 59 | + (doseq [[v1 v2] edges] |
| 60 | + (a/insert-edge! (vmap v1) (vmap v2) |
| 61 | + :end-arrow false :rounded true |
| 62 | + :stroke-width "3" |
| 63 | + :stroke-color "#7c3aed")))))) |
| 64 | + |
| 65 | + (def zipper? |
| 66 | + (every-pred vector? (comp #{2} count) (comp map? first) |
| 67 | + (comp (some-fn nil? :changed? :ppath :pnodes) |
| 68 | + second))) |
| 69 | + (def zip-location-viewer |
| 70 | + {:transform-fn (comp v/html (v/update-val #(-> % ->graph ->svg))) |
| 71 | + :pred zipper?}) |
| 72 | + |
| 73 | + (def zip-reel-viewer |
| 74 | + {:pred (every-pred zipper? (comp :cut? meta)) |
| 75 | + :transform-fn (comp :frames meta v/->value) |
| 76 | + :render-fn '(fn [frames] |
| 77 | + (v/html |
| 78 | + (reagent/with-let |
| 79 | + [!reel? (reagent/atom false) !idx (reagent/atom 0) !tmr (reagent/atom nil) |
| 80 | + stepfn #(swap! !idx inc)] |
| 81 | + (cond |
| 82 | + (and @!reel? (not @!tmr)) |
| 83 | + (reset! !tmr (js/setInterval stepfn 500)) |
| 84 | + (and (not @!reel?) @!tmr) |
| 85 | + (do (js/clearInterval @!tmr) (reset! !tmr nil) (reset! !idx 0))) |
| 86 | + [:div.flex.items-left |
| 87 | + [:div.flex.mr-5 {:style {:font-size "1.5rem"}} |
| 88 | + [:div.cursor-pointer {:on-click #(swap! !reel? not)} ({true "⏹" false "▶️"} @!reel?)]] |
| 89 | + (v/inspect (frames (as-> (count frames) c (if @!reel? (mod @!idx c) (dec c)))))])))}) |
| 90 | + |
| 91 | + (defn reset-reel [zloc] (vary-meta zloc assoc :frames [] :cut? false)) |
| 92 | + (defn add-frame [zloc] (vary-meta zloc update :frames (fnil conj []) zloc)) |
| 93 | + (defn cut [zloc] (vary-meta zloc assoc :cut? true)) |
| 94 | + (defmacro zmov-> [subj & ops] |
| 95 | + (list* '-> subj `reset-reel `add-frame |
| 96 | + (concat (interpose `add-frame ops) [`add-frame `cut]))) |
| 97 | + (clerk/add-viewers! [zip-reel-viewer zip-location-viewer])) |
| 98 | +^{::clerk/viewer :hide-result} |
| 99 | +(def ->zip (partial zip/zipper map? :content #(assoc %1 :content %2))) |
| 100 | +^{::clerk/viewer :hide-result} |
| 101 | +(defn ->node [name] {:name name}) |
| 102 | +;; In code cells below, you may read `zmov->` as clojure's own threading macro: |
| 103 | +;; the resulting values are the same while metadata is being varied to contain intermediate "frames". |
| 104 | +(def tree |
| 105 | + (zmov-> (->node 'a) |
| 106 | + ->zip |
| 107 | + (zip/append-child (->node 'b)) |
| 108 | + (zip/append-child (->node 'c)) |
| 109 | + zip/down zip/right |
| 110 | + (zip/append-child (->node 'd)) |
| 111 | + (zip/append-child (->node 'e)) |
| 112 | + (zip/append-child (->node 'f)) |
| 113 | + zip/up)) |
| 114 | + |
| 115 | +;; So given a location |
| 116 | +(def loc |
| 117 | + (zmov-> tree |
| 118 | + zip/down zip/right |
| 119 | + zip/down zip/right |
| 120 | + zip/right)) |
| 121 | + |
| 122 | +;; we'd go up, edit and go back down |
| 123 | +(zmov-> loc |
| 124 | + zip/up |
| 125 | + (zip/edit assoc :name "☹︎︎") |
| 126 | + zip/down) |
| 127 | +;; losing the position we had. This is the scenario pictured by the author: |
| 128 | + |
| 129 | +;; > When an algorithm has frequent operations which necessitate going up in the tree, |
| 130 | +;;and down again at the same position, it is a loss of time (and space, and garbage collecting time, etc) |
| 131 | +;;to close the sections in the meantime. It may be advantageous |
| 132 | +;;to leave “scars” in the structure allowing direct access to the memorized visited |
| 133 | +;;positions. Thus we replace the (non-empty) sections by triples memorizing a tree |
| 134 | +;;and its siblings: |
| 135 | +;; |
| 136 | +;; _**The Zipper** J. Functional Programming 1 (1): 1–000, January 1993_ |
| 137 | +;; |
| 138 | +;; Let's now stick to the rules: reuse Clojure's own data structures while adding a "memorized" functionality. |
| 139 | +;; A Scar is a seq that remembers who's left and right of a given point |
| 140 | +(deftype Scar [left node right] |
| 141 | + Seqable |
| 142 | + (seq [_] (concat left (cons node right)))) |
| 143 | +;; and we're plugging it in the original up and down function definitions: |
| 144 | +^{::clerk/visibility :hide ::clerk/viewer :hide-result} |
| 145 | +(.addMethod ^clojure.lang.MultiFn print-method Scar (get-method print-method clojure.lang.ISeq)) |
| 146 | + |
| 147 | +(defn zip-up-memo [[node path :as loc]] |
| 148 | + (let [{:keys [pnodes ppath l r]} path] |
| 149 | + (when pnodes |
| 150 | + (with-meta [(zip/make-node loc (peek pnodes) (->Scar l node r)) |
| 151 | + (when ppath (assoc ppath :changed? true))] (meta loc))))) |
| 152 | + |
| 153 | +(defn zip-down-memo [[node path :as loc]] |
| 154 | + (when (zip/branch? loc) |
| 155 | + (let [children (zip/children loc)] |
| 156 | + (with-meta [(.node children) |
| 157 | + {:l (.-left children) |
| 158 | + :ppath path |
| 159 | + :pnodes (if path (conj (:pnodes path) node)) |
| 160 | + :r (.-right children)}] (meta loc))))) |
| 161 | + |
| 162 | +;; so now we can go up, edit, go back at the previous location |
| 163 | +(zmov-> loc |
| 164 | + zip-up-memo |
| 165 | + (zip/edit assoc :name "☻") |
| 166 | + zip-down-memo) |
| 167 | + |
| 168 | +;; same works with repeated applications of memo movements |
| 169 | +(zmov-> loc |
| 170 | + zip-up-memo |
| 171 | + zip-up-memo |
| 172 | + (zip/edit assoc :name "☻") |
| 173 | + zip-down-memo |
| 174 | + zip-down-memo) |
| 175 | + |
| 176 | +;; up and down memo is compatible with other zipper operations |
| 177 | +(zmov-> loc |
| 178 | + zip-up-memo |
| 179 | + (zip/insert-left (->node '▶)) |
| 180 | + (zip/insert-right (->node '◀)) |
| 181 | + zip-down-memo) |
| 182 | + |
| 183 | +;; or move away from the remembered position, go back to it, go back memo |
| 184 | +(zmov-> loc |
| 185 | + zip-up-memo |
| 186 | + zip/left |
| 187 | + zip/right |
| 188 | + zip-down-memo) |
| 189 | + |
| 190 | +;; let's get a final crazy reel. |
| 191 | +(zmov-> (->node 'a) |
| 192 | + ->zip |
| 193 | + (zip/append-child (->node 'b)) |
| 194 | + (zip/append-child (->node 'c)) |
| 195 | + zip/down |
| 196 | + (zip/edit update :name str/capitalize) |
| 197 | + zip/right |
| 198 | + (zip/append-child (->node 'd)) |
| 199 | + (zip/append-child (->node 'e)) |
| 200 | + (zip/insert-child (->node 'f)) |
| 201 | + zip/down |
| 202 | + (zip/insert-child (->node 'g)) |
| 203 | + (zip/insert-child (->node 'h)) |
| 204 | + zip/down zip/right zip/remove |
| 205 | + zip/remove |
| 206 | + zip/remove) |
| 207 | + |
| 208 | +^{::clerk/viewer :hide-result ::clerk/visibility :hide} |
| 209 | +(comment |
| 210 | + (clerk/clear-cache!) |
| 211 | + (macroexpand '(zmov-> tree zip/up zip/down)) |
| 212 | + *e) |
0 commit comments