Repository: ctford/traversy Branch: main Commit: 1814dc02f49f Files: 9 Total size: 19.3 KB Directory structure: gitextract_1ak9kg24/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENCE.txt ├── README.md ├── project.clj ├── src/ │ └── traversy/ │ └── lens.cljc └── test/ └── traversy/ ├── test/ │ └── lens.cljc └── test_runner.cljs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '8' - name: Install Leiningen run: | wget https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein chmod +x lein sudo mv lein /usr/local/bin/ lein version - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Cache Leiningen dependencies uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-lein-${{ hashFiles('**/project.clj') }} restore-keys: | ${{ runner.os }}-lein- - name: Run tests run: lein test-all ================================================ FILE: .gitignore ================================================ pom.xml pom.xml.asc *jar /lib/ /classes/ /target/ /doc/ .lein-deps-sum .lein-repl-history .nrepl-port .idea *.iml *.lein-failures /out/ ================================================ FILE: CHANGELOG.md ================================================ 0.5.0 ----- * 'in' distinguishes between absence of value (empty list returned) and nil value (nil returned) 0.4.0 ----- * Support ClojureScript. * Upgrade to Clojure 1.7 to use reader conditionals in the tests. 0.3.1 ----- * `xth` accepts a default value if the index is out of bounds. 0.3.0 ----- * Removed `delete` on map entry lenses. * `in` does nothing if the path does not exist (unlike `update-in`). * Added `indexed` and `conditionally` lenses. * `view-single` throws an error if there are no foci. 0.2.0 ----- * `view` renamed to `view-single`, and `view-all` renamed to `view`. * Confined deletion to entries in a map, as it's dangerous for sets and seqs. 0.1.0 ----- * First version. ================================================ FILE: LICENCE.txt ================================================ Open Source Initiative OSI - The MIT License:Licensing The MIT License Copyright (c) 2014, Chris Ford (christophertford at gmail) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Traversy [![CI](https://github.com/ctford/traversy/workflows/CI/badge.svg)](https://github.com/ctford/traversy/actions) [![Clojars Project](http://clojars.org/traversy/latest-version.svg)](http://clojars.org/traversy) An experimental encoding of multilenses in Clojure. ## What are multilenses? Simply put, multilenses are generalisations of `sequence` and `update-in`. Traversy's `view` and `update` accept a lens that determines how values are extracted or updated. `update-in` provides a way to apply a function within a nested map: ```clojure (-> {:x 2 :y 4} (update-in [:x] inc)) => {:x 3 :y 4} ``` This works great so long as the value you want to update can be addressed by a single path. However, there is no function for updating every value in a map in clojure.core. Here's how it looks with the `all-values` lens: ```clojure (require ['traversy.lens :refer :all]) (-> {:x 2 :y 4} (update all-values inc)) => {:x 3 :y 5} ``` The same lens can also be used for viewing the foci: ```clojure (-> {:x 2 :y 4} (view all-values)) => (2 4) ``` Lenses can be easily composed, so it's easy to build one that suits your particular data structure: ```clojure (-> [{:x 1 :y 2} {:x 2 :y 7}] (update (*> each all-values) inc)) => ({:x 2 :y 3} {:x 3 :y 8}) ``` And as viewing also composes: ```clojure (-> [{:x 1 :y 2} {:x 2 :y 7}] (view (*> each all-values))) => (1 2 2 7) ``` As lenses are first class, once you have one that suits your needs, you can name it and put it in a var. ## Usage See the [examples](test/traversy/test/lens.cljc). There is [API documentation](https://ctford.github.io/traversy/traversy.lens.html), which describes the operations and provided lenses. This was generated by [Codox](https://github.com/weavejester/codox). ## Background At the 2014 Clojure eXchange [I gave a talk about Lenses in general, and Traversy specifically](https://skillsmatter.com/skillscasts/6034-journey-through-the-looking-glass). ## Laws Lenses follow some rules that make them behave intuitively. The first two rules are the [Traversal Laws](http://hackage.haskell.org/package/lens-2.3/docs/Control-Lens-Traversal.html#t:Traversal). The final rule governs the relationship between `update` and `view`. An `update` has no effect if passed the `identity` function: ```clojure (-> x (update l identity)) === x ``` Fusing two updates together is the same as applying them separately: ```clojure (-> x (update l f1) (update l f2)) === (-> (update l (comp f2 f1))) ``` `update` then `view` is the same as `view` then `map`: ```clojure (-> x (update l f) (view l x)) === (->> x (view l) (map f)) ``` These should hold for any lens `l` that applies to a data structure `x`. The second rule can be violated when the foci of a lens change after an `update`. An example of this is when `only` is used with a predicate and function that interact. These two expressions should have the same value, but as incrementing an odd number makes it even, the second update in the first example has no targets: ```clojure (-> [1 2 3] (update (only odd?) inc) (update (only odd?) inc)) => [2 2 4] (-> [1 2 3] (update (only odd?) (comp inc inc))) => [3 2 5] ``` Careful when doing this - and please document any lenses that have this behaviour as unstable. Traversy comes with three unstable lenses: `only`, `maybe` and `conditionally`. ## FAQs ### Aren't these just degenerate Lenses? Yes! In fact, they're degenerate [Traversals](http://hackage.haskell.org/package/lens-2.3/docs/Control-Lens-Traversal.html), with the `Foldable` and `Functor` instances and without the generality of traversing using arbitrary `Applicatives`. ### Will updates preserve the structure of the target? Yes. Whether you focus on a map, a set, a vector or a sequence, the structure of the target will remain the same after an update. ### Can I compose these Lenses with ordinary function composition? No. Unlike [Haskell Lenses](http://hackage.haskell.org/package/lens), these are not represented as functions. You can, however, use `combine` (variadic form `*>`) and `both` (variadic form `+>`) to compose lenses. ### Can I use Traversy with ClojureScript? Yup! ### How do I run the tests? Clojure: `lein test` ClojureScript: `lein test-cljs` (you'll need phantomjs) both: `lein test-all` ### Is this stable enough to use in production? Traversy is in production use on the project it originated from, but the API may yet change. ================================================ FILE: project.clj ================================================ (defproject traversy "0.5.0" :description "Multilenses for Clojure." :url "https://github.com/ctford/traversy" :min-lein-version "2.1.2" :license {:name "MIT" :url "http://opensource.org/licenses/MIT"} :dependencies [[org.clojure/clojure "1.8.0"] [org.clojure/clojurescript "1.9.229"]] :profiles {:dev {:plugins [[com.jakemccrary/lein-test-refresh "0.10.0"] [codox "0.8.10"] [lein-cljsbuild "1.1.4"] [lein-doo "0.1.7"]] :dependencies [[smidjen "0.2.1"]]}} :codox {:src-dir-uri "http://github.com/ctford/traversy/blob/0.3.0/"} :cljsbuild {:builds {:test {:source-paths ["src" "test"] :compiler {:output-to "target/cljs/testable.js" :main traversy.test-runner :target :nodejs :optimizations :none}}}} :aliases {"test-clj" ["test" "traversy.test.lens"] ;; Travis version of lein doesn't support reader conditionals yet "test-cljs" ["doo" "node" "test" "once"] "auto-cljs" ["doo" "node" "test" "auto"] "test-all" ["do" "test-clj," "test-cljs"]}) ================================================ FILE: src/traversy/lens.cljc ================================================ (ns traversy.lens (:refer-clojure :exclude [update])) (defn lens "Construct a lens from a focus function and an fmap function: (focus x) => a sequence of foci (fmap f x) => an updated x" [focus fmap] {:focus focus :fmap fmap}) (defn view "Return a seq of the lens' foci." [x lens] ((:focus lens) x)) (defn view-single "Return the sole focus, throwing an error if there are other or no foci." [x lens] (let [[focus & _ :as foci] (view x lens) quantity (count foci)] (assert (= 1 quantity) (str "Found " quantity " foci, but expected exactly 1.")) focus)) (defn update "Apply f to the foci of x, as specified by lens." [x lens f] ((:fmap lens) f x)) (defn put "When supplied as the f to update, sets all the foci to x." [x] (constantly x)) (defn ^:no-doc fapply [f x] (f x)) (def it "The identity lens (under 'combine')." (lens list fapply)) (defn ^:no-doc fconst [f x] x) (def nothing "The null lens. The identity under 'both'." (lens (constantly []) fconst)) (defn ^:no-doc zero [x] (or (empty x) [])) (defn ^:no-doc map-conj [f x] (->> x (map f) (reduce conj (zero x)))) (def each "A lens from collection -> item." (lens sequence map-conj)) (def ^:no-doc index (partial map vector (range))) (defn ^:no-doc findexed [f x] (map (comp second f) (index x))) (def indexed "A lens from sequence -> index/item pair." (lens index findexed)) (defn ^:no-doc fnth [n f x] (concat (take n x) [(f (nth x n))] (drop (inc n) x))) (defn xth "A lens from collection -> nth item." ([n] (lens (comp list #(nth % n)) (partial fnth n))) ([n not-found] (lens (comp list #(nth % n not-found)) (partial fnth n)))) (defn ^:no-doc fapply-in [path f x] (if (not= (get-in x path ::not-found) ::not-found) (update-in x path f) x)) (defn ^:no-doc focus-in [path not-found x] (let [v (get-in x path not-found)] (if (= v ::not-found) (list) (list v)))) (defn in "A lens from map -> value at path." ([path] (in path ::not-found)) ([path not-found] (lens (partial focus-in path not-found) (partial fapply-in path)))) (defn combine "Combine two lenses to form a new lens." [outer inner] (lens (fn [x] (mapcat #(view % inner) (view x outer))) (fn [f x] (update x outer #(update % inner f))))) (defn *> "Combine lenses to form a new lens." [& lenses] (reduce combine it lenses)) (defn ^:no-doc fwhen [applies? f x] (if (applies? x) (f x) x)) (defn conditionally "A lens to a conditional value, based on a predicate. This lens is unstable if the predicate interacts with an update." [applies?] (lens (fn [x] (if (applies? x) [x] [])) (partial fwhen applies?))) (defn only "A lens from collection -> applicable items, based on a predicate. This lens is unstable if the predicate interacts with an update." [applies?] (*> each (conditionally applies?))) (def maybe "A lens to an optional value. This lens is unstable if an update converts nil to another value. " (conditionally (complement nil?))) (defn both "Combine two lenses in parallel to form a new lens." [one another] (lens (fn [x] (concat (view x one) (view x another))) (fn [f x] (-> x (update one f) (update another f))))) (defn +> "Combine lenses in parallel to form a new lens." [& lenses] (reduce both nothing lenses)) (def all-entries "A lens from map -> each entry." each) (def all-values "A lens from map -> each value." (*> all-entries (in [1]))) (def all-keys "A lens from map -> each key." (*> all-entries (in [0]))) (defn select-entries "A lens from map -> the entries corresponding to ks." [ks] (only (fn [[k v]] ((set ks) k)))) ================================================ FILE: test/traversy/test/lens.cljc ================================================ (ns traversy.test.lens (:refer-clojure :exclude [update]) (:require [traversy.lens :refer [view-single view update it nothing each only in update indexed all-entries all-values all-keys select-entries conditionally put xth combine both *> +> maybe]] [smidjen.core #?(:clj :refer :cljs :refer-macros) [fact facts]])) (fact "The 'it' lens is the identity." (-> 9 (view-single it)) => 9 (-> 9 (view it)) => [9] (-> 9 (update it inc)) => 10) (fact "The 'nothing' lens doesn't have a focus." (-> 9 (view nothing)) => '() (-> 9 (update nothing inc)) => 9) (fact "Trying to 'view-single' a lens that doesn't have exactly one focus throws an error." (-> [9 10] (view-single each)) => (throws #?(:clj AssertionError :cljs js/Error)) (-> [] (view-single each)) => (throws #?(:clj AssertionError :cljs js/Error))) (fact "Using 'view-single' with a multi-focus lens that happens to only have a single focus is fine." (-> [9 10] (view-single (only even?))) => 10) (fact "The 'in' lens focuses into a map based on a path." (-> {} (view (in [:foo]))) => [] (-> {} (update (in [:foo]) str)) => {} (-> {:foo 1} (view-single (in [:foo]))) => 1 (-> {:foo 1} (view (in [:foo]))) => [1] (-> {:foo 1} (update (in [:foo]) inc)) => {:foo 2} (-> {:foo nil} (update (in [:foo]) str)) => {:foo ""} (-> {:foo 1} (view-single (in [:bar] "not-found"))) => "not-found" (-> {:foo 1} (view-single (in [:bar]))) => (throws #?(:clj AssertionError :cljs js/Error))) (fact "Unlike 'update-in', 'in' does nothing if the specified path does not exist." (-> {} (update (in [:foo]) identity)) => {}) (fact "The 'each' lens focuses on each item in a sequence." (-> [1 2 3] (view each)) => [1 2 3] (-> [] (view each)) => '() (-> [1 2 3] (update each inc)) => #(and (= % [2 3 4]) (vector? %))) (fact "The 'each' lens focuses on each element in a set." (-> #{1 2 3} (view each) set) => #{1 2 3} (-> #{} (view each)) => '() (-> #{1 2 3} (update each inc) set) => #{2 3 4}) (fact "The 'each' lens focuses on the entries of a map." (-> {:foo 3 :bar 4} (view each) set) => #{[:foo 3] [:bar 4]} (-> {} (view each)) => '() (-> {:foo 3 :bar 4} (update each (fn [[k v]] [v k]))) => {3 :foo 4 :bar}) (fact "The 'indexed' lens focuses on indexed pairs in a sequence." (-> [1 2 3] (view indexed)) => [[0 1] [1 2] [2 3]] (-> [1 2 3] (update indexed (fn [[i v]] [i (+ i v)]))) => [1 3 5]) (fact "The 'all-entries' lens focuses on the entries of a map." (-> {:foo 3 :bar 4} (view all-entries) set) => #{[:foo 3] [:bar 4]} (-> {} (view all-entries)) => '() (-> {:foo 3 :bar 4} (update all-entries (fn [[k v]] [v k]))) => {3 :foo 4 :bar}) (fact "The 'all-values' lens focuses on the values of a map." (-> {:foo 1 :bar 2} (view all-values) set) => #{1 2} (-> {:foo 1 :bar 2} (update all-values inc)) => {:foo 2 :bar 3}) (fact "The 'all-keys' lens focuses on the keys of a map." (-> {:foo 1 :bar 2} (view all-keys) set) => #{:foo :bar} (-> {:foo 1 :bar 2} (update all-keys {:foo :frag :bar :barp})) => {:frag 1 :barp 2}) (fact "The 'conditionally' lens focuses only on foci that match a condition." (-> 1 (view (conditionally odd?))) => [1] (-> 1 (view (conditionally even?))) => '() (-> {:foo 1 :bar 2} (view (*> (+> (in [:foo]) (in [:bar])) (conditionally odd?)))) => [1] (-> 1 (update (conditionally odd?) inc)) => 2 (-> 1 (update (conditionally even?) inc)) => 1) (fact "The 'maybe' lens focuses only on foci that are present." (-> {:foo 1} (view (*> (in [:foo]) maybe))) => [1] (-> {:foo 1} (view (*> (in [:bar]) maybe))) => '() (-> {} (view (*> (in [:bar]) maybe))) => '() (-> {:foo 1} (view (*> (+> (in [:foo]) (in [:bar])) maybe))) => [1] (-> 1 (update maybe inc)) => 2 (-> nil (update maybe inc)) => nil?) (fact "The 'only' lens focuses on the items in a sequence matching a condition." (-> [1 2 3] (view (only even?))) => [2] (-> [1 2 3] (update (only even?) inc)) => [1 3 3] (-> #{1 2 3} (update (only even?) inc)) => #{1 3}) (fact "The 'select-entries' lens focuses on entries of a map specified by key." (-> {:foo 3 :bar 4 :baz 5} (view (select-entries [:foo :bar])) set) => #{[:foo 3] [:bar 4]} (-> {:foo 3 :bar 4 :baz 5} (update (select-entries [:foo :bar]) (fn [[k v]] [v k]))) => {3 :foo 4 :bar :baz 5}) (fact "put sets the value at all the foci of a lens." (-> [1 2 3] (update (only even?) (put 7))) => [1 7 3] (-> #{1 2 3} (update each (put 7))) => #{7} (-> {:foo 3 :bar 4} (update (select-entries [:foo]) (put [:baz 7]))) => {:bar 4 :baz 7}) (fact "The 'xth' lens focuses on the nth item of a sequence." (-> [2 3 4] (view-single (xth 1))) => 3 (-> [2 3 4] (view (xth 1))) => [3] (-> [2 3 4] (update (xth 1) inc)) => [2 4 4] (-> [2 3 4] (view-single (xth 4 "not found"))) => "not found" (-> [2 3 4] (view (xth 4 "not found"))) => ["not found"]) (fact "We can 'combine' single-focus lenses." (-> {:foo {:bar 9}} (view-single (combine (in [:foo]) (in [:bar])))) => 9 (-> {:foo {:bar 9}} (view (combine (in [:foo]) (in [:bar])))) => [9] (-> {:foo {:bar 9}} (update (combine (in [:foo]) (in [:bar])) inc)) => {:foo {:bar 10}}) (fact "We can 'combine' multiple-focus lenses with single-focus lenses." (-> [{:foo 1} {:foo 2}] (view (combine each (in [:foo])))) => [1 2] (-> [{:foo 1} {:foo 2}] (update (combine each (in [:foo])) inc)) => [{:foo 2} {:foo 3}]) (fact "We can 'combine' multiple-focus lenses with multiple-focus lenses." (-> [[1 2] [3]] (view (combine each each))) => [1 2 3] (-> [[1 2] [3]] (update (combine each each) inc)) => [[2 3] [4]]) (fact "We can combine single-focus lenses with multiple-focus lenses." (-> {:foo [1 2]} (view (combine (in [:foo]) each))) => [1 2] (-> {:foo [1 2]} (update (combine (in [:foo]) each) inc)) => {:foo [2 3]}) (fact "We can combine n lenses with '*>'." (-> {:foo {:bar {:baz 9}}} (view-single (*> (in [:foo]) (in [:bar]) (in [:baz])))) => 9 (-> {:foo {:bar {:baz 9}}} (view (*> (in [:foo]) (in [:bar]) (in [:baz])))) => [9] (-> {:foo {:bar {:baz 9}}} (update (*> (in [:foo]) (in [:bar]) (in [:baz])) inc)) => {:foo {:bar {:baz 10}}}) (fact "We can combine lenses in parallel with 'both'." (-> {:foo 8 :bar 9} (view (both (in [:foo]) (in [:bar])))) => [8 9] (-> {:foo 8 :bar 9} (update (both (in [:foo]) (in [:bar])) inc)) => {:foo 9 :bar 10}) (fact "We can combine lenses in parallel with '+>'." (-> {:foo 8 :bar 9 :baz 10} (view (+> (in [:foo]) (in [:bar]) (in [:baz])))) => [8 9 10] (-> {:foo 8 :bar 9 :baz 10} (update (+> (in [:foo]) (in [:bar]) (in [:baz])) inc)) => {:foo 9 :bar 10 :baz 11}) ================================================ FILE: test/traversy/test_runner.cljs ================================================ (ns traversy.test-runner (:require [doo.runner :refer-macros [doo-all-tests]] [traversy.test.lens])) (doo-all-tests #"traversy\.test\..*")