Liking cljdoc? Tell your friends :D

7. nav — static navigation

Slicing and outlines (build first)

The cheapest, highest-value piece, needing neither LSP nor a REPL. Verified: babashka bundles rewrite-clj with position metadata, so this is a zero-new-dependency starting point.

  • outline — one entry per top-level form. Turns a 900-line namespace into 40 lines and forces a decision about what to read. Include (comment …) blocks; that is where the REPL experiments live.

    The runtime half built 2026-08-05 (outline.clj, surfaced as __r/outline): everything the glance needs — arglists, docstrings, :line, the macro/private/dynamic flags — already sits on the loaded vars, with no file plumbing and no parse. {:ns … :doc … :vars […]} in source order, privates included and marked (the gist of a namespace, not just its API), the ns docstring's first sentence as orientation, :include-docstrings carrying every docstring whole. The default flipped twice on 2026-08-05: docstrings-on when this call was the initialize instructions' bootstrap and both fresh tenants stripped the option; back to the signatures-only glance the same day, when the rendered manual moved into the instructions themselves and outline returned to being purely a reading tool. :match (string case-insensitive, or regex) filters by name or docstring; in the glance mode docstring hits quote their matching lines as :matched — the answer shows its evidence, grep-style. Namespace designators: symbol, string, the namespace itself, or an alias resolved against the eval's *ns* — (outline '__r) works wherever the eval has navigated, which is what lets the initialize instructions bootstrap ("start with (__r/outline '__r)") stay a one-liner. defmethod defines no var, so a var holding a MultiFn lists its :dispatches — without it, mcp's whole handle surface would be invisible. :line survives AOT (measured: this JVM's compiled clojure.core reports map at 2744), though following it needs the source on the classpath. The view is the running system's, not the file's — measured immediately: the live registry still interns announcements!, a var renamed told! on disk long ago, because :reload never unmaps. Honest for a runtime tool, and the reason the source outline planned above (unloaded files, comment blocks, the file as written) remains separate, unbuilt work. Two options only, both instances of the one knob §7 permits — how much crosses into context; :match searches text bare mode deliberately doesn't ship.

    nses and source built 2026-08-05, completing the drilldown gradient: nses → outline → doc/source, system → namespace → var, every step the same two moves (glance, then match). nses (in outline.clj, reusing its matcher; surfaced as __r/nses) is the missing entry point: every loaded namespace as a sorted vector of bare symbols — plain composable data, deliberately names-only. Measured: 94 namespaces in this JVM, ~2.1k chars — the no-arg form is safe unfiltered. Loaded namespaces, not the classpath; classpath enumeration is the source half's. source (an __r macro over clojure.repl/source, the doc sibling — printed, not returned) is the first written-half reader, needing no rewrite-clj because verbatim text is a resource read: source-fn follows :file/:line to the classpath, which works for anything shipped as source — verified on remontoire's own vars (privates included, consistent with outline), on q/timeout (a dependency, full source with comments), and prints Source not found for AOT-only vars and unresolvable names alike. The live/written distinction stays visible at the call site: outline/doc say what is live, source says what is written, and after a hot reload they can differ — the pair, not a switch. Motivating context: pattern namespaces (real, loadable, testable code under something like co.multiply.remontoire.patterns.*) become indexable by (nses \"patterns\") and readable by the same tools as everything else — the single-source doctrine a third time, since a pattern that stops compiling breaks the load instead of quietly rotting. Multi-eval recipes want the whole-file grain ((comment …) blocks are invisible to var-based source) — that reader arrives with the pattern book.

    file built the same day — the whole-file grain: a namespace's source file, verbatim, as a string. Feasibility measured first: Clojure has no semi-compiled intermediate — libraries ship the actual .clj/.cljc files as jar resources (even AOT'd clojure.core carries clojure/core.clj verbatim; only a deliberately source-stripped jar loses it), and a var's :file metadata is the classpath-relative resource path, straight from the loader. Lookup order: munged namespace path first (.clj then .cljc), so a namespace assembled from several files — clojure.core loads five — answers with its defining file; var :file metadata is the fallback for unconventional layouts; no hit throws a plain explanation carrying :tried. Returns the string rather than printing, unlike doc and source — settled on the channels' actual semantics: the value renderer's caps (*print-length*/*print-level*) bound collections, not string length, so a returned string never truncates but arrives pr-str'd with escaped newlines; the output bank is the mirror image — verbatim but capped at 32k, announced. So composition (the primitive: split-lines, drop-while to a section) gets the return value, and reading is the one-wrap incantation (println (file 'some.ns)) through the capped channel. The exposed footgun, named in the docstring: a bare (file 'clojure.core) returns 276k chars (measured) with nothing to bound it until shape — "mind the size, count is cheap, slice before returning". Verified: remontoire .clj, quiescent .cljc via munge, clojure.core's defining file, string and alias designators, the no-file throw, and the compose path landing drop-while on a named defn in one expression. Locally io/resource resolves through the directory classpath at call time, so file reads the current disk bytes — verified with a marker comment seen mid-session with no reload — which gives the live/written pair diagnostic teeth: file vs outline disagreement is the edited-but-not-reloaded state. Deployed from a jar, frozen at build. nses also grew symbol leeway the same day (a symbol is matched as its name), making (nses 'co.multiply.quiescent) the is-it-really-loaded check — it answers with the loaded family, [] for not loaded.

    The pattern book built 2026-08-05 — co.multiply.remontoire.patterns.*, six recipe namespaces (cleanup, bounding, partial-results, integrations, outliving, parallelism) under an umbrella ns that requires them all; mcp requires the umbrella so the drilldown finds them — being loaded is what cashes "breaks the load instead of quietly rotting," and it is also the discipline: defns and (comment …) blocks only, nothing effectful at top level, because these load inside the host JVM at boot. Each recipe is real code — with-resource (the loan pattern), bounded (q/timeout, and the ns docstring carries the await-vs-timeout distinction: await bounds the response, timeout bounds the work), reporting (progress via the output rails), from-cf (callable proof a CompletableFuture grounds bare), detached (compel-around-construction as a macro, making the inline-ness syntactic), gated (bounded fan-out) — with walkthroughs in comment blocks written as eval code, assuming the scratch refers and __r, readable through file where var-grain tools cannot see them. All verified live on build day: the loan released on the cancellation path (the reason finally is the recipe), both bounded arms, a CF grounding to its value through the entry, 8 results in order through a 3-wide gate. Only quiescent and the JDK appear in compiled positions — core.async stays inside a comment block, honest about being host-conditional. The ambient pointer is one instructions sentence: (__r/nses \"patterns\") is the index.

    vars built 2026-08-07 — the global entry point the first in-situ smoke test found missing: "where does that function live?" required guessing namespaces, the one call the tenant reached for and didn't find (ranked first on its wishlist by felt friction). outline's :match machinery mapped across all-ns: hits are outline entries with :ns attached, ordered by namespace then source line, docstring hits quoting their :matched evidence as ever. A match is required — the JVM's whole var population is no glance — and it takes what nses takes: string (case-insensitive substring), regex, or a symbol matched as its name, so (vars 'normalize-str) is the natural ask. Hits are scored into three tiers — exact name match, name hit, docstring-only hit — namespace-then-source-line order within each tier: the definition asked for leads, the mentions follow, and the shape stays one flat vector (the ordering is the ranking; no score field, no second result shape). Build day shipped grep-style flat order, unranked — a call that sounded principled and was falsified by first use the same day (see the logbook). No index, nothing to go stale: linear meta reads at call time, measured fast enough not to matter (see the logbook). The composability invariant holds by construction — a hit's :ns designates for outline and file, and (symbol (str ns) (str name)) feeds doc/source.

  • form — exact source, and critically including the preceding comment block and any metadata or >defn spec. Cutting those off is precisely the damage line-guessing does today.

  • changed-forms — git diff -U0 → changed line numbers → enclosing top-level forms, emitted whole. This is the direct answer to "I updated function F, have a look", and it is roughly 30 lines of code. Run kondo on the result and inline the diagnostics; it is already parsing those forms.

Cross-references

Use the clj-kondo analysis database rather than text search: it resolves aliases, separates definition from usage, and disambiguates same-named vars across modules.

Verified against fluent-platform: its .clj-kondo/config.edn:31-46 already lint-ases e/defn, e/def, defdepot, defpstate, defresource, defservice, plus real hooks for Rama, Electric hooks, guardrails and uix. Both ($ nav2/Routes) (alias-qualified) and ($ Reroute) (unqualified, same-ns) resolve correctly in var-usages. So the Electric $ macro is not the blind spot it appears to be.

The real blind spot is components and handlers held as values. src/co/multiply/client.cljc:126 does (if-let [InitState (get-in +route-match [:data :nav/state])] ($ InitState)), and :128 the same with Handler. Those are locals, correctly absent from var-usages. Any registry or route table storing a component is invisible. Therefore:

  • "0 usages" must be reported as "0 static usages", never as dead. Text search stays the fallback.
  • Report analysis freshness. A confidently wrong answer from a stale cache is worse than a grep. Re-lint touched files on demand.

Once usages returns re-feedable symbols, graph queries become caller code rather than library features:

;; everything that transitively reaches execute-tool
(loop [frontier #{'co.multiply.module.driver.impl/execute-tool} seen #{}]
  (let [next (into #{} (comp (mapcat nav/usages) (keep :sym) (remove seen)) frontier)]
    (if (empty? next) seen (recur next (into seen next)))))

Run inward it is wedge diagnosis; run outward from entry points it is tree-shake reachability, which previously had to be hand-verified.

:sym can be nil — usages at top level have no enclosing var (a def referencing another var, (comment …) bodies, defmethod dispatch); kondo omits :from-var there. Design it in: mark such sites :top-level true so the reason is visible, and keep is the idiom.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close