Skip to content

Commit 3bc058e

Browse files
authored
Support macros defined in notebooks (#771)
This fixes the analysis for when macros are defined in the namespace that's shown. To support this, we introduce an optional macro evaluation phase as part of the analysis. Fixes #746.
1 parent 180902a commit 3bc058e

File tree

4 files changed

+162
-79
lines changed

4 files changed

+162
-79
lines changed

src/nextjournal/clerk/analyzer.clj

Lines changed: 89 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,16 @@
156156
deps (set/union (set/difference (into #{} (map (comp symbol var->protocol)) @!deps) vars)
157157
deref-deps
158158
(when (var? form) #{(symbol form)}))
159-
hash-fn (-> form meta :nextjournal.clerk/hash-fn)]
159+
hash-fn (-> form meta :nextjournal.clerk/hash-fn)
160+
macro? (-> analyzed :env :defmacro)]
160161
(cond-> {#_#_:analyzed analyzed
161162
:form form
162163
:ns-effect? (some? (some #{'clojure.core/require 'clojure.core/in-ns} deps))
163164
:freezable? (and (not (some #{'clojure.core/intern} deps))
164165
(<= (count vars) 1)
165166
(if (seq vars) (= var (first vars)) true))
166-
:no-cache? (no-cache? form (-> def-node :form second) *ns*)}
167+
:no-cache? (no-cache? form (-> def-node :form second) *ns*)
168+
:macro macro?}
167169
hash-fn (assoc :hash-fn hash-fn)
168170
(seq deps) (assoc :deps deps)
169171
(seq deref-deps) (assoc :deref-deps deref-deps)
@@ -335,7 +337,7 @@
335337
(let [{:as form-analysis :keys [ns-effect? form]} (cond-> (analyze (:form block))
336338
(:file doc) (assoc :file (:file doc)))
337339
block+analysis (add-block-id (merge block form-analysis))]
338-
(when ns-effect? ;; needs to run before setting doc `:ns` via `*ns*`
340+
(when ns-effect?
339341
(eval form))
340342
(-> state
341343
(store-info block+analysis)
@@ -442,7 +444,10 @@
442444

443445
(defn var->location [var]
444446
(when-let [file (:file (meta var))]
445-
(some-> (if (fs/absolute? file)
447+
(some-> (if (try (fs/absolute? file)
448+
;; fs/absolute? crashes in bb on Windows due to the :file
449+
;; metadata containing "<expr>"
450+
(catch Exception _ false))
446451
(when (fs/exists? file)
447452
(fs/relativize (fs/cwd) (fs/file file)))
448453
(when-let [resource (io/resource file)]
@@ -496,45 +501,91 @@
496501
(filter (comp #{:code} :type)
497502
blocks))))
498503

504+
(defn transitive-deps
505+
([id analysis-info]
506+
(loop [seen #{}
507+
deps #{id}
508+
res #{}]
509+
(if (seq deps)
510+
(let [dep (first deps)]
511+
(if (contains? seen dep)
512+
(recur seen (rest deps) res)
513+
(let [{new-deps :deps} (get analysis-info dep)
514+
seen (conj seen dep)
515+
deps (concat (rest deps) new-deps)
516+
res (into res deps)]
517+
(recur seen deps res))))
518+
res))))
519+
520+
#_(transitive-deps id analysis-info)
521+
522+
#_(transitive-deps :main {:main {:deps [:main :other]}
523+
:other {:deps [:another]}
524+
:another {:deps [:another-one :another :main]}})
525+
526+
(defn run-macros [init-state]
527+
(let [{:keys [blocks ->analysis-info]} init-state
528+
macro-block-ids (keep #(when (:macro %)
529+
(:id %)) blocks)
530+
deps (mapcat #(transitive-deps % ->analysis-info) macro-block-ids)
531+
all-block-ids (into (set macro-block-ids) deps)
532+
all-blocks (filter #(contains? all-block-ids (:id %)) blocks)]
533+
(doseq [block all-blocks]
534+
(try
535+
;; (println "loading in namespace" *ns* (:text block))
536+
(load-string (:text block))
537+
(catch Throwable e
538+
(binding [*out* *err*]
539+
(println "Error when evaluating macro deps:" (:text block))
540+
(println "Namespace:" *ns*)
541+
(println "Exception:" e)))))
542+
(pos? (count all-blocks))))
543+
499544
(defn build-graph
500545
"Analyzes the forms in the given file and builds a dependency graph of the vars.
501546
502-
Recursively decends into dependency vars as well as given they can be found in the classpath.
547+
Recursively descends into dependency vars as well if they can be found in the classpath.
503548
"
504549
[doc]
505-
(loop [{:as state :keys [->analysis-info analyzed-file-set counter]}
506-
(-> doc
507-
analyze-doc
508-
(assoc :analyzed-file-set (cond-> #{} (:file doc) (conj (:file doc)))
509-
:counter 0
510-
:graph (dep/graph)))]
511-
(let [unhashed (unhashed-deps ->analysis-info)
512-
loc->syms (apply dissoc
513-
(group-by find-location unhashed)
514-
analyzed-file-set)]
515-
(if (and (seq loc->syms) (< counter 10))
516-
(recur (-> (reduce (fn [g [source symbols]]
517-
(let [jar? (or (nil? source)
518-
(str/ends-with? source ".jar"))
519-
gitlib-hash (and (not jar?)
520-
(second (re-find #".gitlibs/libs/.*/(\b[0-9a-f]{5,40}\b)/" (fs/unixify source))))]
521-
(if (or jar? gitlib-hash)
522-
(update g :->analysis-info merge (into {} (map (juxt identity
523-
(constantly (if source
524-
(or (when gitlib-hash {:hash gitlib-hash})
525-
(hash-jar source))
526-
{})))) symbols))
527-
(-> g
528-
(update :analyzed-file-set conj source)
529-
(merge-analysis-info (analyze-file source))))))
530-
state
531-
loc->syms)
532-
(update :counter inc)))
533-
(-> state
534-
analyze-doc-deps
535-
set-no-cache-on-redefs
536-
make-deps-inherit-no-cache
537-
(dissoc :analyzed-file-set :counter))))))
550+
(binding [*ns* (:ns doc)]
551+
(let [init-state-fn #(-> doc
552+
analyze-doc
553+
(assoc :analyzed-file-set (cond-> #{} (:file doc) (conj (:file doc)))
554+
:counter 0
555+
:graph (dep/graph)))
556+
init-state (init-state-fn)
557+
ran-macros? (run-macros init-state)
558+
init-state (if ran-macros?
559+
(init-state-fn)
560+
init-state)]
561+
(loop [{:as state :keys [->analysis-info analyzed-file-set counter]} init-state]
562+
(let [unhashed (unhashed-deps ->analysis-info)
563+
loc->syms (apply dissoc
564+
(group-by find-location unhashed)
565+
analyzed-file-set)]
566+
(if (and (seq loc->syms) (< counter 10))
567+
(recur (-> (reduce (fn [g [source symbols]]
568+
(let [jar? (or (nil? source)
569+
(str/ends-with? source ".jar"))
570+
gitlib-hash (and (not jar?)
571+
(second (re-find #".gitlibs/libs/.*/(\b[0-9a-f]{5,40}\b)/" (fs/unixify source))))]
572+
(if (or jar? gitlib-hash)
573+
(update g :->analysis-info merge (into {} (map (juxt identity
574+
(constantly (if source
575+
(or (when gitlib-hash {:hash gitlib-hash})
576+
(hash-jar source))
577+
{})))) symbols))
578+
(-> g
579+
(update :analyzed-file-set conj source)
580+
(merge-analysis-info (analyze-file source))))))
581+
state
582+
loc->syms)
583+
(update :counter inc)))
584+
(-> state
585+
analyze-doc-deps
586+
set-no-cache-on-redefs
587+
make-deps-inherit-no-cache
588+
(dissoc :analyzed-file-set :counter))))))))
538589

539590
(comment
540591
(reset! !file->analysis-cache {})

src/nextjournal/clerk/analyzer/impl.clj

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,12 @@
3838
(let [local? (and (simple-symbol? sym)
3939
(contains? (:locals env) sym))]
4040
(when-not local?
41-
(when (symbol? sym)
42-
(let [sym-ns (when-let [ns (namespace sym)] (symbol ns))
43-
full-ns (resolve-ns sym-ns env)]
44-
(when (or (not sym-ns) full-ns)
45-
(let [name (if sym-ns (-> sym name symbol) sym)]
46-
(binding [*ns* (or full-ns ns)]
47-
(resolve name))))))))))
41+
(let [sym-ns (when-let [ns (namespace sym)] (symbol ns))
42+
full-ns (resolve-ns sym-ns env)]
43+
(when (or (not sym-ns) full-ns)
44+
(let [name (if sym-ns (-> sym name symbol) sym)]
45+
(binding [*ns* (or full-ns ns)]
46+
(resolve name)))))))))
4847

4948
(defn resolve-sym-node [{:keys [env] :as ast}]
5049
(assert (= :symbol (:op ast)))
@@ -384,7 +383,10 @@
384383
(:macro (meta maybe-macro)))
385384
(do
386385
(swap! *deps* conj maybe-macro)
387-
(let [expanded (macroexpand-hook maybe-macro form env (rest form))]
386+
(let [expanded (macroexpand-hook maybe-macro form env (rest form))
387+
env (if (identical? #'defmacro maybe-macro)
388+
(assoc env :defmacro true)
389+
env)]
388390
(analyze* env expanded)))
389391
{:op :invoke
390392
:form form
@@ -427,7 +429,9 @@
427429
:ns ns
428430
:resolved-to v
429431
:type (type v)})))
430-
(let [meta (-> (dissoc (meta sym) :inline :inline-arities)
432+
(let [meta (-> (dissoc (meta sym) :inline :inline-arities
433+
;; babashka has :macro on var symbol through defmacro
434+
:macro)
431435
(update-vals unquote'))]
432436
(intern (ns-sym ns) (with-meta sym meta))))))
433437

@@ -447,14 +451,15 @@
447451
(assoc-in env [:namespaces ns :mappings sym] var)))
448452
args (when-let [[_ init] (find args :init)]
449453
(assoc args :init (analyze* env init)))]
450-
(merge {:op :def
451-
:env env
452-
:form form
453-
:name sym
454-
:doc (or (:doc args) (-> sym meta :doc))
455-
:children (into [:meta] (when (:init args) [:init]))
456-
:var (get-in env [:namespaces ns :mappings sym])
457-
:meta {:val (meta sym)}}
454+
(merge (cond-> {:op :def
455+
:env env
456+
:form form
457+
:name sym
458+
:doc (or (:doc args) (-> sym meta :doc))
459+
:children (into [:meta] (when (:init args) [:init]))
460+
:var (get-in env [:namespaces ns :mappings sym])
461+
:meta {:val (meta sym)}}
462+
(:defmacro env) (assoc :macro true))
458463
args)))
459464

460465
(defmethod -parse 'fn* [env [op & args :as form]]

src/nextjournal/clerk/eval.clj

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -281,30 +281,29 @@
281281

282282
(defn +eval-results
283283
"Evaluates the given `parsed-doc` using the `in-memory-cache` and augments it with the results."
284-
[in-memory-cache {:as parsed-doc :keys [set-status-fn no-cache]}]
285-
(if (cljs? parsed-doc)
286-
(process-cljs parsed-doc)
287-
(let [{:as analyzed-doc :keys [ns]}
288-
289-
(cond
290-
no-cache
291-
parsed-doc
292-
293-
config/cache-disabled?
294-
(assoc parsed-doc :no-cache true)
295-
296-
:else
297-
(do
298-
(when set-status-fn
299-
(set-status-fn {:progress 0.10 :status "Analyzing…"}))
300-
(-> parsed-doc
301-
(assoc :blob->result in-memory-cache)
302-
analyzer/build-graph
303-
analyzer/hash)))]
304-
(when (and (not-empty (:var->block-id analyzed-doc))
305-
(not ns))
306-
(throw (ex-info "namespace must be set" (select-keys analyzed-doc [:file :ns]))))
307-
(binding [*ns* ns]
284+
[in-memory-cache {:as parsed-doc :keys [ns set-status-fn no-cache]}]
285+
(binding [*ns* ns]
286+
(if (cljs? parsed-doc)
287+
(process-cljs parsed-doc)
288+
(let [{:as analyzed-doc :keys [ns]}
289+
(cond
290+
no-cache
291+
parsed-doc
292+
293+
config/cache-disabled?
294+
(assoc parsed-doc :no-cache true)
295+
296+
:else
297+
(do
298+
(when set-status-fn
299+
(set-status-fn {:progress 0.10 :status "Analyzing…"}))
300+
(-> parsed-doc
301+
(assoc :blob->result in-memory-cache)
302+
analyzer/build-graph
303+
analyzer/hash)))]
304+
(when (and (not-empty (:var->block-id analyzed-doc))
305+
(not ns))
306+
(throw (ex-info "namespace must be set" (select-keys analyzed-doc [:file :ns]))))
308307
(eval-analyzed-doc analyzed-doc)))))
309308

310309
(defn eval-doc

test/nextjournal/clerk/eval_test.clj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,34 @@
287287
(clerk/show! 'nextjournal.clerk.fixtures.hello)
288288
(is (fs/exists? (:file (meta (resolve 'nextjournal.clerk.fixtures.hello/answer)))))))
289289

290+
(deftest macro-analysis-test
291+
(testing "macros are executed before analysis such that expressions relying on
292+
them get properly cached and executed once"
293+
(remove-ns 'my-random-namespace)
294+
(remove-ns 'fixture-ns)
295+
(clerk/clear-cache!)
296+
(let [ns "(ns my-random-namespace)
297+
(defn macro-helper* [x] x)
298+
299+
(defmacro attempt1
300+
[& body]
301+
`(macro-helper* (try
302+
(do ~@body)
303+
(catch Exception e# e#))))
304+
305+
306+
(def a1
307+
(do
308+
(println \"a1\")
309+
(attempt1 (rand-int 9999))))"
310+
_ (eval/eval-string ns)
311+
first-rand @(resolve 'my-random-namespace/a1)
312+
_ (eval/eval-string ns)
313+
second-rand @(resolve 'my-random-namespace/a1)]
314+
(is (= first-rand second-rand)))))
315+
316+
#_@(resolve 'my-random-namespace/a1)
317+
290318
(deftest issue-741-can-eval-quoted-regex-test
291319
(is (match? {:blocks [{:type :code,
292320
:result {:nextjournal/value "foo"}}]}

0 commit comments

Comments
 (0)