A library sometimes mixes Clojure source with C# compiled ahead of time to a plain assembly committed to the repo, say a hand-written parser class built with csc. On the JVM the equivalent, .class files on a :paths directory, just works: the classpath is how types get resolved. The CLR has no classpath for types. :import only searches assemblies already loaded in the process; it never loads one. So something must call assembly-load-from before the importing namespace is analyzed, or the :import fails with a missing type.
This is not a MAGIC quirk. Stock ClojureCLR behaves the same way, and its assembly-load / assembly-load-from / assembly-load-file are the official mechanism; there is nothing higher-level, on either cljr or nos. Every CLR Clojure library that ships a precompiled assembly has to load it explicitly. The only question is where that load lives.
The official way to load an assembly is assembly-load-from, called before the :import runs. The common shortcut is to hardcode the DLL's path, but that breaks the moment the library is consumed from another directory. Instead, scan CLOJURE_LOAD_PATH (the CLR's load path) for the DLL from a small namespace, and require it from the namespace that does the :import. Clauses run in written order, so put the :require before the :import, and the assembly loads first. This is the plain stock-ClojureCLR pattern, and it works unchanged on MAGIC because nos puts the project's :paths on CLOJURE_LOAD_PATH too:
(ns my-lib.load-dll
"The CLR does not load an assembly at :import; load the compiled DLL
explicitly. Scan CLOJURE_LOAD_PATH, which carries the project :paths
on both stock ClojureCLR and nostrand."
#?(:cljr (:require [clojure.string :as str]))
#?(:cljr (:import [System.IO Path File])))
#?(:cljr
(let [roots (some-> (Environment/GetEnvironmentVariable "CLOJURE_LOAD_PATH")
(str/split (re-pattern (str Path/PathSeparator))))]
(when-let [dll (some (fn [root]
(let [p (Path/Combine root "my_lib.dll")]
(when (File/Exists p) p)))
roots)]
(assembly-load-from dll))))The importing namespace requires it in the same ns form:
(ns my-lib.core
#?(:cljr (:require [my-lib.load-dll]))
#?(:cljr (:import [my_lib MyParser])))The directory holding the DLL (say src_classes) must be on the project's :paths, so the scan finds it. The diagram below shows the order.
flowchart LR
req["require<br/>my-lib.core"] --> ld["my-lib.load-dll runs<br/>scans the load path"]
ld --> found{"DLL on<br/>a load path?"}
found -->|yes| al["assembly-load-from"] --> imp[":import resolves"]
found -->|no| pre["already loaded<br/>(Unity plugins)"] --> imp
Keeping the load in the require graph is what makes it work everywhere, with no toolchain support. One placement covers every case:
nos buildandnos test, including MAGIC AOT compile time: the compiler evaluates top-level forms as it compiles, so the assembly is loaded before the:importis analyzed.- Stock ClojureCLR's
cljr, which puts:pathson the sameCLOJURE_LOAD_PATHthe loader scans. - Consumers pulling the library as a git dep: their require of
my-lib.coreruns the loader in their own process. - Unity players: the scan finds nothing and the loader is a no-op, which is correct, because assemblies under
Assets/Pluginsare already loaded.
A library that must build on both stock ClojureCLR and MAGIC should pair the loader with a self-contained deps-clr.edn whose :paths include the DLL directory. Both cljr and nos read that one file and put every :paths entry on CLOJURE_LOAD_PATH, so the loader's scan finds the assembly on either runtime. See Declaring CLR dependencies.
When the C# source references Clojure types, the compiler needs the runtime assemblies. nos where prints the directory the running host loaded them from, so a build script never guesses install dirs:
csc -deterministic -target:library -r:$(nos where Clojure.dll) -out:src_classes/my_interop.dll MyInterop.csThe -deterministic flag is what keeps the committed DLL byte-stable: without it, csc stamps a random module id, so rebuilding unchanged source modifies the committed file every time. Byte-stability also needs a pinned AssemblyVersion (a 1.0.* wildcard derives the version from the build time) and the same compiler version across rebuilds, so expect a toolchain upgrade to change the bytes once. If the build emits debug info (-debug:portable), add -pathmap:"$PWD"=/src so the embedded source paths do not tie the bytes to one checkout directory.