diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bf74e..d9b95f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.6.35 (2026-07-18) +- Fix nested macro expansion: a macro expanding to a fully qualified call of + another macro namespace's macro was emitted as a runtime call, like squint +- Share the macro scan and macro lookup with squint. Namespaces flagged + `{:squint/compile-time true}` load only their compile-time part into the + macro environment, like squint + - Fix `deftype` implementing cljs.core protocols such as `Inst`, `IIterable` and `IAtom`: their marker properties were Closure-renamed in the precompiled core and missing from the emitter's core protocol set. The externs list and diff --git a/bb/integration_tests.clj b/bb/integration_tests.clj index f2b8a4e..79d6553 100644 --- a/bb/integration_tests.clj +++ b/bb/integration_tests.clj @@ -58,6 +58,13 @@ (is (str/includes? out "22")) (is (str/includes? out "1")))) +(deftest nested-macro-test + (t/testing "a macro expansion calling another macro ns fully qualified expands, not a runtime call" + (let [out (:out (sh {:err :inherit + :dir "test-resources/test_project"} + "npx cherry run nested_macro_test.cljs"))] + (is (str/includes? out "25"))))) + (deftest equality-test (let [out (:out (sh {:err :inherit :dir "test-resources/test_project"} diff --git a/deps.edn b/deps.edn index e8fb7c3..13c515f 100644 --- a/deps.edn +++ b/deps.edn @@ -2,7 +2,7 @@ :deps {org.babashka/cli {:mvn/version "0.11.72"} org.babashka/sci {:mvn/version "0.15.56"} io.github.squint-cljs/squint - {:git/sha "79c06128cbe749477e49983fb47752b664e000d3"} + {:git/sha "a50e1d200ec9bdc352e644d74a4a2ea0e1bca925"} #_{:local/root "/Users/borkdude/dev/squint"}} :aliases {:cljs {:extra-paths ["test"] diff --git a/src/cherry/compiler.cljc b/src/cherry/compiler.cljc index 1cde60c..7973c61 100644 --- a/src/cherry/compiler.cljc +++ b/src/cherry/compiler.cljc @@ -241,37 +241,7 @@ macro (when (and (symbol? head) (not (:squint.compiler/skip-macro mexpr))) (or (built-in-macros (strip-core-symbol head)) - (let [ns (namespace head) - nm (name head) - ns-state @(:ns-state env) - current-ns (:current ns-state) - nms (symbol nm) - current-ns-state (get ns-state current-ns)] - (if ns - (let [nss (symbol ns)] - (or - ;; used by cherry embed: - (some-> env :macros (get nss) (get nms)) - (let [macro-alias-ns (get-in current-ns-state [:macro-aliases nss]) - resolved-ns (or macro-alias-ns - (get-in current-ns-state [:aliases nss] nss)) - macro-ns (cc/resolve-macro-ns resolved-ns (:target env))] - (or (get-in ns-state [:macros macro-ns nms]) - ;; Built-in fallback. Only consult if the user actually - ;; required this ns. Check after we know there's a candidate. - (when-let [m (get-in built-in-macro-nss [macro-ns nms])] - (when (or (contains? (:aliases current-ns-state) nss) - (contains? (:macro-aliases current-ns-state) nss) - (contains? (:aliases current-ns-state) - (symbol (cc/alias-munge (str nss))))) - m)))))) - (let [refers (:refers current-ns-state)] - (when-let [macro-ns (get refers nms)] - (let [resolved (cc/resolve-macro-ns macro-ns (:target env))] - (or (some-> env :macros (get (symbol macro-ns)) (get nms)) - (get-in ns-state [:macros resolved nms]) - (get-in ns-state [:macros macro-ns nms]) - (get-in built-in-macro-nss [resolved nms])))))))))] + (cc/lookup-macro head env built-in-macro-nss)))] (if macro (let [;; fix for calling macro with more than 20 args #?@(:cljs [macro (or (.-afn ^js macro) macro)]) diff --git a/src/cherry/compiler/node.cljs b/src/cherry/compiler/node.cljs index 9ffcc64..c5d2736 100644 --- a/src/cherry/compiler/node.cljs +++ b/src/cherry/compiler/node.cljs @@ -1,44 +1,17 @@ (ns cherry.compiler.node (:require - ["crypto" :as crypto] ["fs" :as fs] ["path" :as path] [cherry.compiler :as compiler] [clojure.string :as str] - [edamame.core :as e] [shadow.esm :as esm] - [squint.compiler-common :as cc] + [squint.internal.node.macro-scan :as ms] [squint.internal.node.utils :as utils])) (def sci (atom nil)) (def config-file "cherry.edn") -;; Tracks macro source files so we only re-eval (`:reload`) a macro namespace -;; when its file actually changed. Without this, the persistent SCI instance -;; keeps the first-loaded macro defs forever in watch mode (squint#819); -;; reloading on every compile would re-eval untouched macro nses instead of a -;; microsecond stat. path -> {:mtime _ :sha _}. -(def macro-state (atom {})) - -(defn- sha256 [s] - (-> (crypto/createHash "sha256") (.update s) (.digest "hex"))) - -(defn- macro-file [macro-ns] - (utils/resolve-file macro-ns (:paths (utils/get-cfg config-file) ["." "src"]))) - -(defn- macro-file-changed? - "True when macro-ns's source file changed since last seen. Cheap mtime gate - first; only on mtime change do we read + sha256 to confirm real content - change (suppresses spurious reloads on touch-without-edit). Updates cache." - [macro-ns] - (when-let [path (macro-file macro-ns)] - (let [{:keys [mtime sha]} (get @macro-state path) - cur-mtime (.-mtimeMs (fs/statSync path))] - (when (not= cur-mtime mtime) - (let [cur-sha (sha256 (fs/readFileSync path "utf-8"))] - (swap! macro-state assoc path {:mtime cur-mtime :sha cur-sha}) - (not= cur-sha sha)))))) (defn slurp [f] (fs/readFileSync f "utf-8")) @@ -46,88 +19,18 @@ (defn spit [f s] (fs/writeFileSync f s "utf-8")) -(defn- cljc-with-macros? - "Check if a require clause refers to a .cljc file that contains defmacro." - [libspec] - (let [libname (if (symbol? libspec) libspec (first libspec))] - (when (symbol? libname) - (when-let [path (macro-file libname)] - (and (str/ends-with? path ".cljc") - (str/includes? (fs/readFileSync path "utf-8") "defmacro")))))) - -(defn- as-alias? [libspec] - (and (sequential? libspec) - (some #{:as-alias} libspec))) - -(defn- self-ref? [the-ns-name libspec] - (and (sequential? libspec) (= the-ns-name (first libspec)))) - -(defn scan-macros [s {:keys [ns-state]}] - (let [maybe-ns (e/parse-next (e/reader s) compiler/cherry-parse-opts)] - (when (and (seq? maybe-ns) - (= 'ns (first maybe-ns))) - (let [[_ns the-ns-name & clauses] maybe-ns - [require-macros reload] (some (fn [[clause reload]] - (when (and (seq? clause) - (= :require-macros (first clause))) - [(rest clause) reload])) - (partition-all 2 1 clauses)) - ;; a regular :require of a .cljc that contains defmacro loads its - ;; macros too, like squint - require-libs (some->> clauses - (some (fn [clause] - (when (and (seq? clause) - (= :require (first clause))) - (rest clause))))) - require-cljc (->> require-libs - (remove #(self-ref? the-ns-name %)) - (remove as-alias?) - (filter cljc-with-macros?)) - require-macros (concat require-macros require-cljc)] - (when (seq require-macros) - (.then (esm/dynamic-import "./compiler.sci.js") - (fn [_] - (let [eval-form (:eval-form @sci)] - (reduce - (fn [prev require-macros] - (.then prev - (fn [_] - (let [;; a libspec may be a bare symbol, normalize to vector - require-macros (if (symbol? require-macros) - [require-macros] - require-macros) - [macro-ns & {:keys [refer as]}] require-macros - actual-ns (cc/resolve-macro-ns macro-ns :cherry) - file (macro-file actual-ns) - built-in (get compiler/built-in-macro-nss actual-ns) - reload? (or reload (macro-file-changed? actual-ns)) - macros (cond - file - (js/Promise.resolve - (do (eval-form (cond-> (list 'require (list 'quote actual-ns)) - reload? (concat [:reload]))) - (let [publics (eval-form - `(ns-publics '~actual-ns)) - macros (keep (fn [[k v]] - (when (:macro (meta v)) - [k (deref v)])) publics) - macros (into {} macros)] - macros))) - built-in - (js/Promise.resolve built-in) - :else - (throw (js/Error. (str "Could not locate macro namespace " actual-ns))))] - (.then macros - (fn [macros] - (swap! ns-state (fn [ns-state] - (cond-> (assoc-in ns-state [:macros actual-ns] macros) - as (-> (assoc-in [the-ns-name :aliases as] macro-ns) - (assoc-in [the-ns-name :macro-aliases as] macro-ns)) - refer (update-in [the-ns-name :refers] - merge - (zipmap refer (repeat macro-ns)))))))))))) - (js/Promise.resolve nil) - require-macros))))))))) +(def dialect + {:parse-opts compiler/cherry-parse-opts + :features #{:cherry :cljs :default} + :config-file config-file + :target :cherry + :import-sci (fn [] (esm/dynamic-import "./compiler.sci.js")) + :sci sci}) + +(def compile-time-source (partial ms/compile-time-source dialect)) + +(defn scan-macros [s opts] + (ms/scan-macros dialect s opts)) (defn default-ns-state [] (atom {:current 'user})) diff --git a/src/cherry/compiler/sci.cljs b/src/cherry/compiler/sci.cljs index 5bbc813..d158af0 100644 --- a/src/cherry/compiler/sci.cljs +++ b/src/cherry/compiler/sci.cljs @@ -36,10 +36,11 @@ {}) (when-let [f (resolve-macro-file namespace)] (let [fstr (slurp f)] + ;; flagged ns -> its compile-time part only. ;; :file lets SCI bind *file* so an error ;; while loading this ns names its source. {:file f - :source fstr})))) + :source (or (cn/compile-time-source fstr) fstr)})))) :namespaces {'cljs.test test-ns 'clojure.test test-ns 'cherry.test test-ns} diff --git a/test-resources/test_project/nested_macro_test.cljs b/test-resources/test_project/nested_macro_test.cljs new file mode 100644 index 0000000..52025f8 --- /dev/null +++ b/test-resources/test_project/nested_macro_test.cljs @@ -0,0 +1,4 @@ +(ns nested-macro-test + (:require-macros [macros-outer :refer [outer]])) + +(prn (outer 5)) diff --git a/test-resources/test_project/src/macros_inner.cljc b/test-resources/test_project/src/macros_inner.cljc new file mode 100644 index 0000000..0042fdf --- /dev/null +++ b/test-resources/test_project/src/macros_inner.cljc @@ -0,0 +1,4 @@ +(ns macros-inner) + +(defmacro inner [x] + (list '* x x)) diff --git a/test-resources/test_project/src/macros_outer.cljc b/test-resources/test_project/src/macros_outer.cljc new file mode 100644 index 0000000..827ef16 --- /dev/null +++ b/test-resources/test_project/src/macros_outer.cljc @@ -0,0 +1,5 @@ +(ns macros-outer + (:require [macros-inner :as inner])) + +(defmacro outer [x] + `(inner/inner ~x))