diff --git a/app/malli/instrument_app.cljs b/app/malli/instrument_app.cljs index e6253d9c0..8e34bd0bd 100644 --- a/app/malli/instrument_app.cljs +++ b/app/malli/instrument_app.cljs @@ -5,13 +5,15 @@ [malli.dev.cljs :as dev] [malli.dev.pretty :as pretty] [malli.generator :as mg] + [malli.dev.cljs :as md] [malli.instrument.cljs :as mi])) (defn init [] (js/console.log "INIT!")) (defn refresh {:dev/after-load true} [] - (.log js/console "hot reload")) + ;(.log js/console "hot reload") + ) (defn x+y {:malli/schema [:=> [:cat float? float?] :double]} @@ -163,6 +165,16 @@ (defn plusX [x] (inc x)) (m/=> plusX [:=> [:cat :int] MyInt]) +(defn try-it [] + (plus "a")) + +(defn ^:dev/after-load x [] + (println "AFTER LOAD - malli.dev.cljs/start!") + (md/start!) + (js/setTimeout try-it 200) + ) + + (comment ((->minus) 5) (mi/check) diff --git a/deps.edn b/deps.edn index 9c378016d..6245587e3 100644 --- a/deps.edn +++ b/deps.edn @@ -29,7 +29,8 @@ jmh-clojure/task {:mvn/version "0.1.1"}} :main-opts ["-m" "jmh.main"]} :shadow {:extra-paths ["app"] - :extra-deps {thheller/shadow-cljs {:mvn/version "2.17.8"}}} + :extra-deps {thheller/shadow-cljs {:mvn/version "2.17.8"} + binaryage/devtools {:mvn/version "1.0.5"}}} :slow {:extra-deps {io.dominic/slow-namespace-clj {:git/url "https://git.sr.ht/~severeoverfl0w/slow-namespace-clj" :sha "f68d66d99d95f4d2bfd61f001e28a8ad7c4d3a12"}} diff --git a/docs/cljs-instrument-development.md b/docs/cljs-instrument-development.md new file mode 100644 index 000000000..8c57cb7b6 --- /dev/null +++ b/docs/cljs-instrument-development.md @@ -0,0 +1,19 @@ +To work on the function instrumentation for ClojureScript clone the malli repository and: + +```bash +npm i +./node_modules/.bin/shadow-cljs watch instrument +``` + +Open an nREPL connection from your favorite editor to the port located in `.shadow-cljs/nrepl.port` + +Open a browser to `http://localhost:8000` + +the port is set in the `shadow-cljs.edn` file should you wish to change it. + + +In your editor evaluate: + +`(shadow/repl :instrument)` + +The dev-time code is located in the file: `app/malli/instrument_app.cljs`. diff --git a/docs/clojurescript-function-instrumentation.md b/docs/clojurescript-function-instrumentation.md new file mode 100644 index 000000000..57dafcf25 --- /dev/null +++ b/docs/clojurescript-function-instrumentation.md @@ -0,0 +1,94 @@ +# ClojureScript Function Instrumentation + +Function instrumentation is also supported when developing ClojureScript browser applications. + +Things work differently from the Clojure version of instrumentation because there are no runtime Vars in ClojureScript and thus the +instrumentation must happen at compile-time using macros. +The macro will emit code that `set!`s the function at runtime with a version that validates the function's inputs and outputs +against its declare malli schema. + +# Dev Setup + +For the best developer experience make sure you install the latest version of binaryage/devtools and use a chromium based browser: + +https://clojars.org/binaryage/devtools + +if you are using shadow-cljs just ensure this library is on the classpath. + +For an application that uses React.js such as Reagent you will typically declare an entry namespace and init function in your `shadow-cljs.edn` config like so: + +```clj +{... +:modules {:app {:entries [your-app.entry-ns] +:init-fn your-app.entry-ns/init}} +...} +``` + +In your init namespace require `malli.dev.cljs`: + +```clj +(require [malli.dev.cljs :as md]) +``` + +and invoke `start!` in your init function before rendering your application: + +```clj +(defn ^:export init [] + (md/start!) + (my-app/mount!) +``` + +When you save source code files during development and new code is hot-reloaded the non-instrumented versions will now +overwrite any instrumented versions. + +To instrument the newly loaded code with shadow-cljs we can use the [lifecylce hook](https://shadow-cljs.github.io/docs/UsersGuide.html#_lifecycle_hooks) +`:after-load` by adding metadata to a function and invoking `malli.dev.cljs/start!` again: + +```clj +(defn ^:dev/after-load reload [] + (md/start!) + (my-app/mount!)) +``` + +It is useful to understand what is happening when you invoke `(malli.dev.cljs/start!)` + +The line where `start!` lives in your code will be replaced by a block of code that looks something like: + +```clj +(set! your-app-ns/a-function + (fn [& args] + :; validate the args against the input schema + ;; invoke the function your-app-ns/a-function and validate the output against the output schema + ;; return the output + ) +;; assuming an implementation in your-app-ns like: +(defn a-function + {:malli/schema [:=> [:cat :int] :string]} + [x] + (str x)) +``` + +(you can see what is actually output here: https://github.com/metosin/malli/blob/400dc0c79805028a6d85413086d4d6d627231940/src/malli/instrument/cljs.clj#L69) + +And this is why the order of loaded code will affect the instrumented functions. If the code for `your-app-ns/a-function` +is hot-reloaded and the `start!` call is never invoked again, the function will no longer be instrumented. + +## Errors in the browser console + +When you get a schema validation error and instrumentation is on you will see an exception in the browser devtools. + +A validation error looks like this: + + + +If you click the arrow that is highlighted in the above image you will see the error message: + + + +and if you click the arrow highlighted in this above image you will see the stracktrace: + + + +the instrumented function is the one with the red rectangle around it in the image above. + +If you click the filename (`instrument_app.cljs` in this example) the browser devtools will open a file viewer at the problematic call-site. diff --git a/docs/img/cljs-instrument/cljs-instrument-error-collapsed.png b/docs/img/cljs-instrument/cljs-instrument-error-collapsed.png new file mode 100644 index 000000000..b5a62ce2d Binary files /dev/null and b/docs/img/cljs-instrument/cljs-instrument-error-collapsed.png differ diff --git a/docs/img/cljs-instrument/cljs-instrument-error-expanded.png b/docs/img/cljs-instrument/cljs-instrument-error-expanded.png new file mode 100644 index 000000000..ec6542df6 Binary files /dev/null and b/docs/img/cljs-instrument/cljs-instrument-error-expanded.png differ diff --git a/docs/img/cljs-instrument/cljs-instrument-stacktrace-expanded.png b/docs/img/cljs-instrument/cljs-instrument-stacktrace-expanded.png new file mode 100644 index 000000000..3ff8e5a83 Binary files /dev/null and b/docs/img/cljs-instrument/cljs-instrument-stacktrace-expanded.png differ diff --git a/src/malli/dev/cljs.cljc b/src/malli/dev/cljs.cljc index 34d9db258..dbe358a79 100644 --- a/src/malli/dev/cljs.cljc +++ b/src/malli/dev/cljs.cljc @@ -1,6 +1,7 @@ (ns malli.dev.cljs #?(:cljs (:require-macros [malli.dev.cljs])) - #?(:cljs (:require [malli.instrument.cljs])) + #?(:cljs (:require [malli.instrument.cljs] + [malli.dev.pretty :as pretty])) #?(:clj (:require [malli.clj-kondo :as clj-kondo] [malli.dev.pretty :as pretty] [malli.instrument.cljs :as mi]))) @@ -24,11 +25,11 @@ #?(:clj (defmacro start! "Collects defn schemas from all loaded namespaces and starts instrumentation for a filtered set of function Vars (e.g. `defn`s). See [[malli.core/-instrument]] for possible options. - Differences from Clojure malli.dev/start: + Differences from Clojure `malli.dev/start!`: - - Does not emit clj-kondo type annotations. + - Does not emit clj-kondo type annotations. See `malli.clj-kondo/print-cljs!` to print clj-kondo config. - Does not re-instrument functions if the function schemas change - use hot reloading to get a similar effect." - ([] (start!* &env {:report `(pretty/reporter)})) + ([] (start!* &env {:report `(pretty/thrower)})) ([options] (start!* &env options)))) #?(:clj (defmacro collect-all! [] (mi/-collect-all-ns))) diff --git a/src/malli/dev/pretty.cljc b/src/malli/dev/pretty.cljc index 70a889dec..13747c793 100644 --- a/src/malli/dev/pretty.cljc +++ b/src/malli/dev/pretty.cljc @@ -39,27 +39,30 @@ (-block "Schema:" (v/-visit schema printer) printer) :break :break (-block "More information:" (-link "https://cljdoc.org/d/metosin/malli/CURRENT" printer) printer)]})) -(defmethod v/-format ::m/invalid-input [_ _ {:keys [args input]} printer] +(defmethod v/-format ::m/invalid-input [_ _ {:keys [args input fn-name]} printer] {:body [:group (-block "Invalid function arguments:" (v/-visit args printer) printer) :break :break + #?(:cljs (-block "Function Var:" (v/-visit fn-name printer) printer)) :break :break (-block "Input Schema:" (v/-visit input printer) printer) :break :break (-block "Errors:" (-explain input args printer) printer) :break :break (-block "More information:" (-link "https://cljdoc.org/d/metosin/malli/CURRENT/doc/function-schemas" printer) printer)]}) -(defmethod v/-format ::m/invalid-output [_ _ {:keys [value output]} printer] +(defmethod v/-format ::m/invalid-output [_ _ {:keys [value output fn-name] :as args} printer] {:body [:group (-block "Invalid function return value:" (v/-visit value printer) printer) :break :break + #?(:cljs (-block "Function Var:" (v/-visit fn-name printer) printer)) :break :break (-block "Output Schema:" (v/-visit output printer) printer) :break :break (-block "Errors:" (-explain output value printer) printer) :break :break (-block "More information:" (-link "https://cljdoc.org/d/metosin/malli/CURRENT/doc/function-schemas" printer) printer)]}) -(defmethod v/-format ::m/invalid-arity [_ _ {:keys [args arity schema]} printer] +(defmethod v/-format ::m/invalid-arity [_ _ {:keys [args arity schema fn-name]} printer] {:body [:group (-block (str "Invalid function arity (" arity "):") (v/-visit args printer) printer) :break :break (-block "Function Schema:" (v/-visit schema printer) printer) :break :break + #?(:cljs (-block "Function Var:" (v/-visit fn-name printer) printer)) :break :break (-block "More information:" (-link "https://cljdoc.org/d/metosin/malli/CURRENT/doc/function-schemas" printer) printer)]}) ;; @@ -72,7 +75,8 @@ (fn [type data] (-> (ex-info (str type) {:type type :data data}) (v/-exception-doc printer) - (v/-print-doc printer))))) + (v/-print-doc printer) + #?(:cljs (-> with-out-str println)))))) (defn thrower ([] (thrower (-printer))) diff --git a/src/malli/dev/virhe.cljc b/src/malli/dev/virhe.cljc index 4b485b73c..b8a7a592a 100644 --- a/src/malli/dev/virhe.cljc +++ b/src/malli/dev/virhe.cljc @@ -24,7 +24,8 @@ (defn -color [color body printer] (let [colors (:colors printer -dark-colors) color (get colors color (:error colors))] - [:span [:pass (str "\033[38;5;" color "m")] body [:pass "\u001B[0m"]])) + #?(:cljs [:span body] + :clj [:span [:pass (str "\033[38;5;" color "m")] body [:pass "\u001B[0m"]]))) ;; ;; EDN diff --git a/src/malli/instrument/cljs.clj b/src/malli/instrument/cljs.clj index 7c3ad3a38..6b03ef5eb 100644 --- a/src/malli/instrument/cljs.clj +++ b/src/malli/instrument/cljs.clj @@ -52,17 +52,21 @@ ;; instrument ;; -(defn -emit-instrument-fn [env {:keys [gen filters] :as instrument-opts} {:keys [schema] :as schema-map} ns-sym fn-sym] +(defn -emit-instrument-fn [env {:keys [gen filters report] :as instrument-opts} + {:keys [schema] :as schema-map} ns-sym fn-sym] ;; gen is a function (let [schema-map (-> schema-map (select-keys [:gen :scope :report]) ;; The schema passed in may contain cljs vars that have to be resolved at runtime in cljs. - (assoc :schema `(m/function-schema ~schema))) + (assoc :schema `(m/function-schema ~schema)) + (cond-> report + (assoc :report `(cljs.core/fn [type# data#] (~report type# (assoc data# :fn-name '~fn-sym)))))) schema-map-with-gen - (as-> (merge (select-keys instrument-opts [:scope :report :gen]) schema-map) $ - ;; use the passed in gen fn to generate a value - (cond (and gen (true? (:gen schema-map))) (assoc $ :gen gen) - :else (dissoc $ :gen))) + (as-> (merge (select-keys instrument-opts [:scope :report :gen]) schema-map) $ + ;; use the passed in gen fn to generate a value + (if (and gen (true? (:gen schema-map))) + (assoc $ :gen gen) + (dissoc $ :gen))) replace-var-code (when (ana-api/resolve env fn-sym) `(do (swap! instrumented-vars #(assoc % '~fn-sym ~fn-sym))