diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 83a4259..0ae4f8e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,22 +14,27 @@ jobs: environment: deploy steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - - uses: DeLaGuardo/setup-clojure@13.4 + - uses: actions/setup-java@v4 with: - cli: '1.12.3.1577' + distribution: 'temurin' + java-version: '21' - - uses: actions/cache@v4.3.0 + - uses: DeLaGuardo/setup-clojure@13.5 + with: + cli: '1.12.4.1582' + + - uses: actions/cache@v5.0.2 with: path: ~/.m2 key: default-build - run: clojure -T:build ci - - uses: codecov/codecov-action@v5.5.1 + - uses: codecov/codecov-action@v5.5.2 - name: deploy if: github.event.release diff --git a/README.adoc b/README.adoc index 63cc4f1..a6fee9b 100644 --- a/README.adoc +++ b/README.adoc @@ -90,9 +90,10 @@ Some of the most frequently used APIs are: <2> by `assoc` key-value pairs to a fun-map, you turn Datomic API into a map. == Further read - - xref:doc/rational.adoc[Rational] + - xref:doc/rationale.adoc[Rationale] - xref:doc/change_log.adoc[Changes] - xref:doc/concepts.adoc[Concepts] + - xref:doc/improvements.adoc[Planned Improvements] == Development diff --git a/WARP.md b/WARP.md new file mode 100644 index 0000000..1933ea1 --- /dev/null +++ b/WARP.md @@ -0,0 +1,75 @@ +# WARP.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Project Overview + +fun-map is a Clojure/ClojureScript library that provides a map data structure where values are automatically unwrapped when accessed. It supports lazy evaluation, dependency injection between map values, and lifecycle management. + +## Build & Test Commands + +```bash +# Run tests in watch mode (auto-reload on changes) +clj -M:dev:test --watch + +# Run all tests once (CLJ + CLJS) +clj -T:build tests + +# Run Clojure tests only +clj -M:dev:test + +# Run ClojureScript tests only +clj -M:dev:cljs-test + +# Build JAR +clj -T:build ci + +# Deploy to Clojars +clj -T:build deploy + +# Copy clj-kondo config to local dev +clj -T:build copy-clj-kondo-config +``` + +## Architecture + +### Core Concepts + +1. **Value Wrappers** (`wrapper.cljc`): Protocol `ValueWrapper` defines how values are unwrapped. Implementations include: + - `FunctionWrapper` - wraps a function that receives the map and key + - `CachedWrapper` - caches results with optional focus function for invalidation + - `TracedWrapper` - adds tracing/logging capability + +2. **DelegatedMap** (`core.cljc`): The main map implementation that: + - Delegates to an underlying map + - Applies `fn-entry` transformation on access to unwrap values + - Implements full Clojure map interfaces for both CLJ and CLJS + +3. **Macros** (`fun_map.cljc`): + - `fw` - Creates function wrappers with full destructuring and options (`:focus`, `:trace`, `:par?`, `:wrappers`) + - `fnk` - Shortcut for `fw` that auto-focuses on declared keys + +### Key Files + +- `src/robertluo/fun_map.cljc` - Public API: `fun-map`, `fw`, `fnk`, `life-cycle-map`, `closeable`, `halt!` +- `src/robertluo/fun_map/core.cljc` - `DelegatedMap` type with CLJ/CLJS implementations +- `src/robertluo/fun_map/wrapper.cljc` - `ValueWrapper` protocol and wrapper types +- `src/robertluo/fun_map/helper.cljc` - Macro helpers for `fw`/`fnk` expansion + +### Testing + +Tests use kaocha with cloverage. Test files mirror source structure in `test/`. + +## Known Issues & Improvements + +See `doc/improvements.adoc` for a tracked list of potential improvements. + +### Key Gotchas + +1. **~~Missing keys produce cryptic errors~~** - FIXED: now throws helpful `ex-info` with context +2. **~~Circular deps cause StackOverflowError~~** - FIXED: now detects cycles and reports the path +3. **Plain maps inside fun-maps don't unwrap** - use nested fun-maps if you need nested unwrapping +4. **Iteration realizes all values** - `keys`, `vals`, `seq` trigger all computations +5. **`select-keys`/`into` return plain maps** - wrappers are lost +6. **`:keep-ref true` + `fnk`** - `fnk` receives atoms, not values; use `fw` with explicit `@` in focus +7. **`update` on computed key** - replaces `fnk` with the updated value diff --git a/deps.edn b/deps.edn index fa60514..21f5810 100644 --- a/deps.edn +++ b/deps.edn @@ -1,8 +1,8 @@ {:paths ["src"] :aliases {:dev ;for development {:extra-paths ["test"] - :extra-deps {manifold/manifold {:mvn/version "0.4.3"} - org.clojure/clojurescript {:mvn/version "1.11.121"}}} + :extra-deps {manifold/manifold {:mvn/version "0.5.0"} + org.clojure/clojurescript {:mvn/version "1.12.134"}}} :test ;run tests under console. e.g. clj -M:dev:test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"} lambdaisland/kaocha-cloverage {:mvn/version "1.1.89"}} @@ -14,4 +14,4 @@ :build ;customized building process running. e.g. clj -T:build ci {:deps {io.github.robertluo/build-clj {:git/sha "5d45f58cc20747c136bb320c9b13d65d2bf4cf58"}} :ns-default build} - :clj-kondo {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2025.09.22"}}}}} + :clj-kondo {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2026.01.12"}}}}} diff --git a/doc/concepts.adoc b/doc/concepts.adoc index bc713e0..16a80d9 100644 --- a/doc/concepts.adoc +++ b/doc/concepts.adoc @@ -121,3 +121,108 @@ With manifold's excellent `let-flow` macro and its `future` function, if you hav (time (:c m)) ;=> 600 in approx. 3000msec instead of 6000 ---- +## Gotchas and Common Pitfalls + +This section covers behaviors that may surprise new users. + +### Plain maps inside fun-maps don't unwrap + +Only fun-maps unwrap their values. If you nest a plain map inside a fun-map, values in that plain map are not unwrapped: + +[source,clojure] +---- +;; Plain map nested inside fun-map - delays NOT unwrapped +(def m (fun-map {:a {:b (delay 42)}})) ; {:b ...} is a plain map +(:b (:a m)) ;=> #object[clojure.lang.Delay ...] + +;; Nested fun-map - delays ARE unwrapped +(def m (fun-map {:a (fun-map {:b (delay 42)})})) ; inner is also a fun-map +(:b (:a m)) ;=> 42 +(get-in m [:a :b]) ;=> 42 ; get-in works too +---- + +### Iteration realizes all values + +Iterating a fun-map (`keys`, `vals`, `seq`, `reduce-kv`, etc.) triggers computation of all values: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (println "computing b") (* a 2))})) +(keys m) ; prints "computing b" even though you only asked for keys! +---- + +This is because iteration must produce key-value pairs, which requires unwrapping. + +### `select-keys` and `into` return plain maps + +Standard Clojure functions return plain maps, losing fun-map semantics: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(type (select-keys m [:a :b])) ;=> clojure.lang.PersistentArrayMap +(type (into {} m)) ;=> clojure.lang.PersistentArrayMap +---- + +Values are realized during iteration, so the result contains computed values, not wrappers. + +### `update` on computed keys replaces the fnk + +Using `update` on a key with an `fnk` value realizes the value and replaces the wrapper: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(def m2 (update m :b inc)) +(:b m2) ;=> 3 (not a function of :a anymore!) + +;; The fnk was replaced with the literal value 3 +(def m3 (assoc m2 :a 100)) +(:b m3) ;=> 3 (unchanged, no longer depends on :a) +---- + +### `dissoc` silently breaks dependencies + +Removing a key that other `fnk`s depend on doesn't fail until access: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(def m2 (dissoc m :a)) +m2 ;=> looks fine: {:b <>} +(:b m2) ;=> Error! :a is missing +---- + +### `:keep-ref` + `fnk` requires careful handling + +With `:keep-ref true`, `fnk` receives the ref itself, not its value: + +[source,clojure] +---- +(def state (atom [1 2 3])) +(def m (fun-map {:nums state + :count (fnk [nums] (count nums))} ; nums is the atom! + :keep-ref true)) +(:count m) ;=> Error: count not supported on Atom + +;; Correct: use fw with explicit deref +(def m (fun-map {:nums state + :count (fw {:keys [nums] :focus @nums} + (count @nums))} + :keep-ref true)) +---- + +### Printing shows wrapper state + +Printing a fun-map shows raw wrapper state, not computed values: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(println m) ;=> {:a 1, :b <>} +(:b m) ;=> 2 +(println m) ;=> {:a 1, :b <<2>>} +---- + +The `<<...>>` notation indicates wrapper values. `<>` means not yet computed. + diff --git a/doc/improvements.adoc b/doc/improvements.adoc new file mode 100644 index 0000000..2d240fa --- /dev/null +++ b/doc/improvements.adoc @@ -0,0 +1,174 @@ += Improvement Ideas +:toc: + +This document records potential improvements identified through REPL exploration and API review. + +== 1. Better Error Messages + +*Priority:* High + +*Problem:* +Missing keys produce cryptic errors: +[source] +---- +NullPointerException: Cannot invoke "Object.getClass()" because "x" is null +---- + +Circular dependencies cause `StackOverflowError` with no indication of the cycle. + +*Examples:* +[source,clojure] +---- +(def m (fun-map {:a (fnk [missing-key] (inc missing-key))})) +(:a m) ;=> NullPointerException + +(def m (fun-map {:a (fnk [b] b) :b (fnk [a] a)})) +(:a m) ;=> StackOverflowError +---- + +*Suggestion:* + +- Detect missing dependencies and throw `ex-info` with `:missing-key` and `:dependent-key` +- Track access stack to detect and report circular dependencies with the cycle path + +== 2. Plain Maps Inside Fun-Maps Don't Unwrap + +*Priority:* Low (documentation) + +*Problem:* +Only fun-maps unwrap their values. Plain maps nested inside a fun-map do not unwrap: + +[source,clojure] +---- +(def m (fun-map {:a {:b (delay 42)}})) ; {:b ...} is a plain map +(:b (:a m)) ;=> #object[clojure.lang.Delay ...] ; not 42 + +;; With nested fun-map, it works: +(def m (fun-map {:a (fun-map {:b (delay 42)})})) +(get-in m [:a :b]) ;=> 42 +---- + +*Status:* Documented in concepts.adoc. This is expected behavior, not a bug. + +== 3. Iteration Triggers All Computations + +*Priority:* Low (documentation) + +*Problem:* +Iterating a fun-map (including `keys`, `vals`, `seq`, `reduce-kv`) triggers all computations. Side effects interleave with output: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (println "computing") (* a 2))})) +(keys m) +;; prints: (:a computing +;; :b) +---- + +*Suggestion:* +Document that iteration realizes all values. This is expected but may surprise users. + +== 4. `select-keys` and `into` Lose Fun-Map Semantics + +*Priority:* Low + +*Problem:* +Standard Clojure functions return plain maps: + +[source,clojure] +---- +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(type (select-keys m [:a :b])) ;=> clojure.lang.PersistentArrayMap +(type (into {} m)) ;=> clojure.lang.PersistentArrayMap +---- + +*Suggestion:* +Document as expected behavior (values are realized during iteration). Optionally provide `select-keys*` that preserves wrappers for advanced use cases. + +== 5. `:keep-ref` Interaction with `fnk` Is Confusing + +*Priority:* Medium + +*Problem:* +With `:keep-ref true`, `fnk` receives the atom itself, not the dereferenced value. But `fnk` auto-generates focus on the *binding* (the atom), not its contents. + +[source,clojure] +---- +(def state (atom [1 2 3])) +(def m (fun-map {:nums state + :count (fnk [nums] (count nums))} ; nums is atom, not vector! + :keep-ref true)) +(:count m) ;=> UnsupportedOperationException: count not supported on Atom +---- + +The correct pattern requires explicit `fw` with `@` in focus: +[source,clojure] +---- +(fw {:keys [nums] :focus @nums} + (count @nums)) +---- + +*Suggestion:* + +- Document this interaction explicitly in the API docs +- Consider a `:deref-focus` option for `fnk` that focuses on `@binding` instead of `binding` + +== 6. Documentation: Missing "Gotchas" Section + +*Priority:* High + +*Problem:* +Several behaviors are surprising to new users: + +1. `update` on a computed key replaces the `fnk` with the computed+updated value +2. Printing a fun-map with unrealized values shows `<>` +3. `dissoc` a dependency breaks dependent `fnk`s silently until accessed + +*Examples:* +[source,clojure] +---- +;; update replaces fnk +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(def m2 (update m :b inc)) +(:b m2) ;=> 3 ; fnk is gone, now just the value 3 + +;; dissoc breaks silently +(def m (fun-map {:a 1 :b (fnk [a] (* a 2))})) +(def m2 (dissoc m :a)) +(:b m2) ;=> NullPointerException (only when accessed) +---- + +*Suggestion:* +Add a "Gotchas" or "Common Pitfalls" section to README or concepts.adoc. + +== 7. `closeable` Could Support `with-open` + +*Priority:* Low + +*Problem:* +`CloseableValue` implements `Haltable` but not `java.io.Closeable`, so it doesn't work with Clojure's `with-open`. + +[source,clojure] +---- +(with-open [r (closeable (create-resource) #(cleanup))] + (use-resource @r)) +;; Error: closeable doesn't implement Closeable +---- + +*Suggestion:* +Extend `CloseableValue` to implement `java.io.Closeable` in CLJ for better interop. The `close` method would delegate to `halt!`. + +== Implementation Status + +[cols="1,3,1"] +|=== +|ID |Description |Status + +|1 |Better error messages |DONE +|2 |Document `get-in` limitation |DONE +|3 |Document iteration behavior |DONE +|4 |Document `select-keys`/`into` behavior |DONE +|5 |Document `:keep-ref` + `fnk` interaction |DONE +|6 |Add gotchas section |DONE +|7 |`Closeable` support for `closeable` |DONE +|=== diff --git a/doc/rational.adoc b/doc/rationale.adoc similarity index 100% rename from doc/rational.adoc rename to doc/rationale.adoc diff --git a/src/robertluo/fun_map.cljc b/src/robertluo/fun_map.cljc index 2d9fbf1..6005cd7 100644 --- a/src/robertluo/fun_map.cljc +++ b/src/robertluo/fun_map.cljc @@ -12,9 +12,9 @@ A fun-map is a special map which will automatically *unwrap* a value if it's a wrapper when accessed by key. A wrapper is anything which wrapped a ordinary value inside. Many clojure data structures are wrapper, such as atom, ref, future, delay, - agent etc. In fact, anything implements clojure.lang.IDRef interface is a wrapper. + agent etc. In fact, anything implements clojure.lang.IDeref interface is a wrapper. - FuntionWrapper is another wrapper can be used in a fun-map, which wraps a function, + FunctionWrapper is another wrapper can be used in a fun-map, which wraps a function, it will be called with the fun-map itself as the argument. Map m is the underlying storage of a fun-map, fun-map does not change its property @@ -22,12 +22,17 @@ Options: - - ::trace-fn An Effectful function for globally FunctionWrapper calling trace which - accept key and value as its argument. + - :trace-fn An effectful function for globally tracing FunctionWrapper calls. + Accepts key and value as arguments. + - :keep-ref When true, IDeref values (delay, future, atom, etc.) will NOT be + automatically dereferenced. Use this when you want to store refs + as actual values. Individual values can still use `fnk` or `fw` + to opt into lazy evaluation. Example: - (fun-map {:a 35 :b (delay (println \"hello from b!\"))}" + (fun-map {:a 35 :b (delay (+ 5 3))}) ; (:b m) => 8 + (fun-map {:a (atom 1)} :keep-ref true) ; (:a m) => #" [m & {:keys [trace-fn keep-ref]}] (with-meta (core/delegate-map m wrapper/wrapper-entry) @@ -42,25 +47,30 @@ (fun-map {:a 1 :b 5 :c (wrapper/fun-wrapper (fn [m _] (let [a (get m :a) b (get m :b)] (+ a b))))}) ) -#?(:clj - (defmacro fw - "Returns a FunctionWrapper of an anonymous function defined by body. +(defmacro fw + "Returns a FunctionWrapper of an anonymous function defined by body. Since a FunctionWrapper's function will be called with the map itself as the - argument, this macro using a map `arg-map` as its argument. It follows the - same syntax of clojure's associative destructure. You may use `:keys`, `:as`, + argument, this macro uses a map `arg-map` as its argument. It follows the + same syntax of Clojure's associative destructuring. You may use `:keys`, `:as`, `:or` inside. - Special key `:wrappers` specify additional wrappers of function wrapper: + Options (special keys in arg-map): + + - `:wrappers` Controls caching and tracing behavior: + - `[]` No caching, no tracing. Function called on every access. + - (default) `[:trace :cache]` - cached and traceable (see below). + + - `:focus` A form evaluated to determine if cached value is stale. + Must be pure and efficient. If the focus value changes, + the function is re-evaluated. Without `:focus`, the function + is called only once (memoized). - - `[]` for naive one, no cache, no trace. - - default to specable cached traceable implementation. which supports special keys: - - `:focus` A form that will be called to check if the function itself need - to be called. It must be pure functional and very effecient. - - `:trace` A trace function, if the value updated, it will be called with key - and the function's return value. + - `:trace` A function `(fn [k v] ...)` called when the wrapped function + is actually invoked (not on cache hits). - Special option `:par? true` will make dependencies accessing parallel. + - `:par?` When true, dependencies are accessed in parallel using + manifold's `let-flow`. Requires manifold on classpath. Example: @@ -68,10 +78,12 @@ :as m :trace (fn [k v] (println k v)) :focus (select-keys m [:a :b])} - (+ a b))" - {:style/indent 1} - [arg-map & body] - (helper/make-fw-wrapper `wrapper/fun-wrapper [:trace :cache] arg-map body))) + (+ a b)) + + Works in both Clojure and ClojureScript." + {:style/indent 1} + [arg-map & body] + (helper/make-fw-wrapper `wrapper/fun-wrapper [:trace :cache] arg-map body)) #?(:clj (defmethod helper/fw-impl :trace @@ -85,16 +97,25 @@ `(fn [~arg-map] ~focus))] `(wrapper/cache-wrapper ~f ~focus)))) -#?(:clj - (defmacro fnk - "A shortcut for `fw` macro. Returns a simple FunctionWrapper which depends on - `args` key of the fun-map, it will *focus* on the keys also." - {:style/indent 1} - [args & body] - (let [focus (mapv (comp symbol name) args)] - `(fw {:keys ~args - :focus ~focus} - ~@body)))) +(defmacro fnk + "A shortcut for `fw` macro. Returns a cached FunctionWrapper that: + 1. Destructures the specified keys from the fun-map + 2. Automatically focuses on those keys (re-evaluates when they change) + + Equivalent to: + (fnk [a b] body) => (fw {:keys [a b] :focus [a b]} body) + + Note: Namespace qualifiers on keys are used for destructuring but stripped + for focus comparison. E.g., `(fnk [:ns/a] ...)` destructures `:ns/a` but + focuses on the local binding `a`. + + Works in both Clojure and ClojureScript." + {:style/indent 1} + [args & body] + (let [focus (mapv (comp symbol name) args)] + `(fw {:keys ~args + :focus ~focus} + ~@body))) (comment (macroexpand-1 '(fnk [a :ns/b] (+ a b))) @@ -126,13 +147,13 @@ (close-fn this))))) (defn life-cycle-map - "returns a fun-map can be shutdown orderly. + "Returns a fun-map that can be shutdown orderly. - Any FunctionWrapper supports `Closeable` in this map will be considered - as a component, its `close` method will be called in reversed order of its - creation when the map itself closing. + Any value satisfying the `Haltable` protocol in this map will be considered + a component. Its `halt!` method will be called in reverse order of creation + when the map itself is halted via `(halt! the-map)`. - Notice only accessed components will be shutdown." + Note: Only accessed components will be shutdown." [m] (let [components (atom []) trace-fn (fn [_ v] @@ -152,14 +173,23 @@ :cljs (-deref [_] value)) Haltable (halt! [_] - (close-fn))) + (close-fn)) + #?@(:clj + [java.io.Closeable + (close [this] + (halt! this))])) (defn closeable - "Returns a wrapped plain value, which implements IDref and Closeable, - the close-fn is an effectual function with no argument. + "Returns a wrapped plain value which implements IDeref, Haltable, and (in CLJ) + java.io.Closeable. The close-fn is an effectful function with no arguments. + + When used inside a life-cycle-map, close-fn will be called when + halting the map via `(halt! the-map)`. + + In Clojure, the returned value works with `with-open`: - When used inside a life cycle map, its close-fn when get called when - closing the map." + (with-open [conn (closeable (create-conn) #(close-conn conn))] + (use-conn @conn))" [r close-fn] (->CloseableValue r close-fn)) diff --git a/src/robertluo/fun_map/wrapper.cljc b/src/robertluo/fun_map/wrapper.cljc index d413440..93f59ef 100644 --- a/src/robertluo/fun_map/wrapper.cljc +++ b/src/robertluo/fun_map/wrapper.cljc @@ -1,5 +1,6 @@ (ns robertluo.fun-map.wrapper - "Protocols that sharing with other namespaces") + "Protocols that sharing with other namespaces" + (:require [clojure.string :as str])) (defprotocol ValueWrapper "A wrapper for a value." @@ -8,6 +9,11 @@ (-unwrap [this m k] "unwrap the real value from a wrapper on the key of k")) +;; Track keys currently being unwrapped to detect circular dependencies +(def ^:dynamic *unwrap-stack* + "Stack of keys currently being unwrapped. Used for cycle detection." + []) + ;; Make sure common value is not wrapped #?(:clj (extend-protocol ValueWrapper @@ -25,11 +31,57 @@ (-unwrap [d _ _] (deref d)))) +(defn- raw-keys + "Get raw keys from a map without triggering unwrapping. + In CLJ, uses rawSeq to avoid triggering unwrapping. + In CLJS, falls back to regular keys (only used in error messages)." + [m] + #?(:clj + (if (instance? robertluo.fun_map.core.IFunMap m) + (map first (.rawSeq ^robertluo.fun_map.core.IFunMap m)) + (keys m)) + :cljs + ;; In CLJS we can't easily reference core without circular dep, + ;; and this is only used for error context, so just use keys on underlying map + (if-let [raw-seq-fn (some-> m meta ::-raw-seq-fn)] + (map first (raw-seq-fn)) + (keys m)))) + +(defn- wrap-fn-error + "Wraps exceptions from function wrapper with context about key and access path." + [f m k] + (try + (f m k) + (catch #?(:clj NullPointerException :cljs js/Error) e + (throw (ex-info (str "Error computing key " (pr-str k) + (when (seq *unwrap-stack*) + (str " (dependency chain: " + (str/join " -> " (map pr-str *unwrap-stack*)) + ")")) + ": " #?(:clj (.getMessage e) :cljs (.-message e)) + ". Possible cause: a dependency key is missing from the map.") + {:type :function-wrapper-error + :key k + :access-path *unwrap-stack* + :available-keys (set (raw-keys m))} + e))) + (catch #?(:clj Exception :cljs :default) e + (throw (ex-info (str "Error computing key " (pr-str k) + (when (seq *unwrap-stack*) + (str " (dependency chain: " + (str/join " -> " (map pr-str *unwrap-stack*)) + ")")) + ": " #?(:clj (.getMessage e) :cljs (.-message e))) + {:type :function-wrapper-error + :key k + :access-path *unwrap-stack*} + e))))) + (deftype FunctionWrapper [f] ValueWrapper (-wrapped? [_ _] true) (-unwrap [_ m k] - (f m k)) + (wrap-fn-error f m k)) #?@(:cljs [IPrintWithWriter (-pr-writer @@ -41,19 +93,40 @@ "construct a new FunctionWrapper" ->FunctionWrapper) +(defn- check-cycle! + "Throws if k is already in the unwrap stack (circular dependency)." + [k] + (when (some #{k} *unwrap-stack*) + (let [cycle-path (conj *unwrap-stack* k)] + (throw (ex-info (str "Circular dependency detected: " + (str/join " -> " (map pr-str cycle-path))) + {:type :circular-dependency + :key k + :cycle cycle-path}))))) + +(defn- unwrap-with-tracking + "Unwrap a value with cycle tracking. Returns the unwrapped value." + [v m k] + (check-cycle! k) + (binding [*unwrap-stack* (conj *unwrap-stack* k)] + (-unwrap v m k))) + (defn wrapper-entry "returns a k,v pair from map `m` and input k-v pair. - If `v` is a wrapped, then recursive unwrap it." + If `v` is a wrapped, then recursive unwrap it. + Detects circular dependencies and provides helpful error messages." [m [k v]] - #?(:clj - (if (-wrapped? v m) - (recur m [k (-unwrap v m k)]) - [k v]) - :cljs - (cond - (satisfies? ValueWrapper v) (recur m [k (-unwrap v m k)]) - (satisfies? IDeref v) (recur m [k (deref v)]) - :else [k v]))) + (loop [v v] + #?(:clj + (if (-wrapped? v m) + (recur (unwrap-with-tracking v m k)) + [k v]) + :cljs + (cond + (satisfies? ValueWrapper v) + (recur (unwrap-with-tracking v m k)) + (satisfies? IDeref v) (recur (deref v)) + :else [k v])))) ;;;;;;;;;;; High order wrappers diff --git a/test/robertluo/fun_map/wrapper_test.cljc b/test/robertluo/fun_map/wrapper_test.cljc index 0a07028..40f99d7 100644 --- a/test/robertluo/fun_map/wrapper_test.cljc +++ b/test/robertluo/fun_map/wrapper_test.cljc @@ -1,6 +1,7 @@ (ns robertluo.fun-map.wrapper-test (:require [clojure.test :refer [deftest is testing]] + [robertluo.fun-map :refer [fun-map fnk]] [robertluo.fun-map.wrapper :as sut])) (deftest wrapper-entry @@ -8,3 +9,42 @@ (is (= [:b 1] (sut/wrapper-entry {:a 1} [:b (sut/fun-wrapper (fn [m _] (get m :a)))])))) (testing "wrapped entry will unwrap deeply until geeting non-wrapped value" (is (= [:b 6] (sut/wrapper-entry {:a 3} [:b (atom (atom 6))]))))) + +(deftest circular-dependency-detection + (testing "self-referencing key throws with cycle info" + (let [m (fun-map {:a (fnk [a] a)})] + (is (thrown-with-msg? #?(:clj clojure.lang.ExceptionInfo :cljs ExceptionInfo) + #"Circular dependency detected: :a -> :a" + (:a m))))) + (testing "two-key cycle throws with cycle path" + (let [m (fun-map {:a (fnk [b] b) :b (fnk [a] a)})] + (is (thrown-with-msg? #?(:clj clojure.lang.ExceptionInfo :cljs ExceptionInfo) + #"Circular dependency detected: :a -> :b -> :a" + (:a m))))) + (testing "three-key cycle throws with full path" + (let [m (fun-map {:a (fnk [b] b) :b (fnk [c] c) :c (fnk [a] a)})] + (is (thrown-with-msg? #?(:clj clojure.lang.ExceptionInfo :cljs ExceptionInfo) + #"Circular dependency detected: :a -> :b -> :c -> :a" + (:a m))))) + (testing "ex-data contains cycle information" + (let [m (fun-map {:a (fnk [b] b) :b (fnk [a] a)}) + ex (try (:a m) nil (catch #?(:clj Exception :cljs :default) e e))] + (is (= :circular-dependency (:type (ex-data ex)))) + (is (= :a (:key (ex-data ex)))) + (is (= [:a :b :a] (:cycle (ex-data ex))))))) + +;; Note: These tests are CLJ-only because in CLJS, (inc nil) returns NaN +;; instead of throwing an exception due to JavaScript's type coercion. +#?(:clj + (deftest error-context-on-failure + (testing "NPE in fnk body provides context about key" + (let [m (fun-map {:a (fnk [missing] (inc missing))}) + ex (try (:a m) nil (catch Exception e e))] + (is (= :function-wrapper-error (:type (ex-data ex)))) + (is (= :a (:key (ex-data ex)))) + (is (= #{:a} (:available-keys (ex-data ex)))))) + (testing "error message mentions the key being computed" + (let [m (fun-map {:result (fnk [x] (inc x))})] + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"Error computing key :result" + (:result m)))))))