MrAnderson 0.6 is out. If you’ve never heard of it, that’s perfectly fine - it’s the kind of tool that’s supposed to do its job quietly and stay out of sight. cider-nrepl has been using MrAnderson to inline its dependencies (bundle private copies of them that can’t clash with yours) for about as long as cider-nrepl has existed, and most of the time you’d never know it was there.

The trouble is the “most of the time”. MrAnderson has always had a few rough edges, and every so often one of them would draw blood during a cider-nrepl release and I’d find myself wondering whether we should just rip the whole thing out. I wasn’t even joking about it - at one point I went and opened a PR to remove MrAnderson from cider-nrepl entirely. That’s roughly how fond of it I was feeling.

What actually pulled me back was a different project. I’d started migrating cider-nrepl’s build from Leiningen to tools.deps, and MrAnderson, being a Leiningen plugin, was squarely in the way - which forced the question of whether to drop dependency inlining altogether or make it work outside of Leiningen. Poking at that turned into poking at MrAnderson’s long-standing issues more broadly, and the grown-up answer won out: fix the thing instead of tearing it out. This release is the result of that effort. It even ships a Leiningen-free entry point (mranderson.core/inline-deps, #42) so it can be driven straight from tools.build, which is what got the ball rolling in the first place.

Before I get into the gory details, huge thanks to Benedek Fazekas, who wrote MrAnderson and has carried it for years. I showed up with a pile of questions and a longer pile of patches, and he was nothing but supportive throughout - he even made me a co-maintainer along the way. None of what follows would have happened without him. Thank you, Benedek!

What problem is this even solving?

cider-nrepl is nREPL middleware, which is a fancy way of saying it gets loaded into your project’s JVM, right next to whatever dependencies you happen to be using. So picture this: cider-nrepl needs one version of rewrite-clj, your project pins a different one. One of them wins, the other breaks, and you get a baffling error that has nothing to do with anything you were working on.

A normal library can shrug and tell you to bump a version. Tooling that injects itself into essentially every Clojure project on the planet doesn’t have that luxury - it has to coexist with everyone’s choices. The trick MrAnderson pulls is to make cider-nrepl stop depending on rewrite-clj in the usual sense at all. Instead, it copies rewrite-clj’s source straight into cider-nrepl under a private prefix:

;; rewrite-clj.zip becomes
cider.nrepl.inlined-deps.rewrite-clj.v1.rewrite-clj.zip

and then rewrites every reference to point at the private copy. Now cider-nrepl carries its own sealed-off rewrite-clj that can’t possibly collide with yours. In the JVM world this is usually called “shading”; in Clojure-land we call it dependency inlining.

Why this is much harder than it looks

“Rename a namespace everywhere” sounds like a job for find-and-replace. It is absolutely not. Clojure refers to a namespace in a startling number of shapes, and they don’t all want the same treatment:

  • ns forms, with :require/:use, :as aliases, :refer, and prefix lists
  • fully-qualified vars in code (some.ns/a-fn)
  • Java interop: :import, fully-qualified class names, type hints, constructors
  • defrecord/deftype, which compile down to Java classes
  • reader conditionals in .cljc, data readers, (load "..."), resource paths
  • metadata and docstrings (which can, of course, contain example code)

And here’s the part that turns it from tedious into genuinely tricky: a dotted, fully-qualified name can be either a namespace/var reference or a Java class/record reference, and the two get munged differently. A namespace keeps its dashes; a class has to follow the JVM’s package rules, where dashes become underscores. Frequently the only way to tell which is which is to look at the surrounding context. On top of all that, a dependency can ship compiled Java .class files that need their own, separate repackaging - and the two mechanisms have to agree with each other.

MrAnderson does a lot of this transformation textually rather than with a full program analyzer, mostly for speed. That’s a reasonable trade-off, but it’s also exactly where the gremlins live.

A taste of what got fixed

Here are a few of those gremlins, now squashed:

  • Records and dashes (#73) - cider-nrepl uses the prefix cider.nrepl.inlined-deps, dash and all. A reference like (instance? instaparse.gll.Failure x) points at a record, which compiles down to a Java class - so the prefix has to follow Java’s package rules, and the dash becomes an underscore (inlined-deps becomes inlined_deps). An ordinary namespace reference, on the other hand, keeps the dash. MrAnderson used to get this wrong and emit a broken reference; it now tells the two apart from the shape of the symbol, the way Clojure’s own reader does.
  • Over-eager imports (#52) - if a dependency shipped its own class in the clojure.lang package, MrAnderson would happily prefix every clojure.lang import it saw, including core classes like clojure.lang.Var. Imports are now rewritten by exact class, not by package.
  • Mixed imports (#33) - (:import [com.acme.impl Deftyped JavaClass]) can mix a deftype-generated class with a real Java class, and the two need different prefixes. The import now gets split so each class lands in the right place.
  • Imports nobody was rewriting (#54, #92) - imports of inlined classes in a dependency’s top-level namespace, and in your own project’s sources, were simply being skipped.
  • load statements (#61) - libraries that split a namespace across (load "...")ed files (hello, tools.deps.alpha) embed a resource path, not a symbol. Those paths weren’t being repointed, so the inlined library couldn’t find its own pieces.
  • The genuinely scary one (#88) - if an inlined library shared a root namespace with your project and shipped AOT .class files, MrAnderson could delete your project’s own sources while cleaning up. That one I was happy to see the back of.

There’s also a good deal of less glamorous work underneath: the Java-class repackaging used to be hardwired to a target/ directory and had no test coverage at all, which is a big part of why these bugs lived as long as they did. It’s now decoupled and tested. My favourite detail: MrAnderson builds its own releases by inlining its own dependencies, so cutting 0.6 was itself a full-scale integration test of the whole thing.

What’s next

There’s still plenty of room for improvement here. The body of each namespace is still rewritten textually rather than properly parsed, and I’d love to see that grow into something more principled one day. Some of the trickier corner cases (the dash-vs-underscore guessing in particular) really want a proper analyzer rather than clever pattern matching. But the first few steps have been encouraging, and for the first time in years I’m not eyeing the eject lever.

If you rely on MrAnderson, give 0.6 a spin - and if you hit a rough edge I missed, the issue tracker is right there. Bug reports with a minimal repro are worth their weight in gold.

That’s all from me for now. Keep hacking!