Skip to content

Commit

Permalink
Fix fn instrumentation errors for ClojureScript in a browser
Browse files Browse the repository at this point in the history
- Print all output from the pretty printer in one string
- Add documentation for working on function instrumentation in the malli codebase
- Add documentation for using malli.dev.cljs as a user of malli
  • Loading branch information
dvingo committed Apr 1, 2022
1 parent 400dc0c commit 9e0ba16
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 17 deletions.
14 changes: 13 additions & 1 deletion app/malli/instrument_app.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
19 changes: 19 additions & 0 deletions docs/cljs-instrument-development.md
Original file line number Diff line number Diff line change
@@ -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`.
94 changes: 94 additions & 0 deletions docs/clojurescript-function-instrumentation.md
Original file line number Diff line number Diff line change
@@ -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:

<img src="img/cljs-instrument/cljs-instrument-error-collapsed.png"/>

If you click the arrow that is highlighted in the above image you will see the error message:

<img src="img/cljs-instrument/cljs-instrument-error-expanded.png"/>

and if you click the arrow highlighted in this above image you will see the stracktrace:

<img src="img/cljs-instrument/cljs-instrument-stacktrace-expanded.png"/>

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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 5 additions & 4 deletions src/malli/dev/cljs.cljc
Original file line number Diff line number Diff line change
@@ -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])))
Expand All @@ -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)))
12 changes: 8 additions & 4 deletions src/malli/dev/pretty.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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)]})

;;
Expand All @@ -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)))
Expand Down
3 changes: 2 additions & 1 deletion src/malli/dev/virhe.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions src/malli/instrument/cljs.clj
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,20 @@
;; 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] :or {report identity} :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)
: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))
Expand Down

0 comments on commit 9e0ba16

Please sign in to comment.