Skip to main content

cljrs_runtime/env/
versioned.rs

1//! Shared versioned-symbol/namespace resolution service.
2//!
3//! This is the single implementation used by every execution tier — the
4//! tree-walking interpreter, the IR interpreter, JIT-compiled code (via the
5//! `rt_load_global*` runtime bridges), and AOT binaries.
6//!
7//! ## Model
8//!
9//! Resolving `ns/name@commit` means: ensure the **versioned namespace**
10//! `"ns@commit"` is loaded (lazily, from an embedded builtin source or from
11//! git history), then perform a plain `lookup_in_ns("ns@commit", name)`.
12//! Versioned namespaces are immutable once loaded, so a resolved value never
13//! changes for the lifetime of the process.
14//!
15//! ## Native (Rust-backed) functions
16//!
17//! Native functions registered via `cljrs-interop::Registry` have no Clojure
18//! source to fetch, and the binary is a single unit at runtime — we can't
19//! "go back in time" to a previous compiled implementation.  The current
20//! contract is: versioned lookups of a native symbol resolve to the HEAD
21//! (current) implementation regardless of the requested commit (see
22//! `native_head_fallback`).
23
24use std::path::Path;
25use std::sync::Arc;
26
27use crate::env::env::{Env, GlobalEnv};
28use crate::env::error::{EvalError, EvalResult};
29use cljrs_value::Value;
30
31/// Strip a trailing `@<commit>` suffix from a namespace name, returning the
32/// base namespace.  `"my.lib@abc1234"` → `"my.lib"`; names without a valid
33/// commit-hash suffix are returned unchanged.
34pub fn base_ns_name(ns: &str) -> &str {
35    cljrs_value::symbol::split_version(ns).0
36}
37
38/// Resolve the value of `name` pinned at `commit`.
39///
40/// - `defining_ns` — the namespace the reference appears in (used for alias
41///   resolution and for unqualified symbols).
42/// - `ns_part` — the symbol's namespace qualifier, if any (may be an alias).
43///
44/// Resolution order:
45/// 1. Determine the base namespace (alias-resolved, `@`-suffix stripped).
46/// 2. Fast path: the versioned namespace `"base@commit"` already has the
47///    binding (it is loaded, or is being loaded right now by this thread).
48/// 3. Version-cache hit (caches native HEAD fallbacks).
49/// 4. Load the versioned namespace (embedded source or git), then look up.
50/// 5. Fall back to the HEAD native binding when no Clojure source exists.
51pub fn resolve_versioned_value(
52    globals: &Arc<GlobalEnv>,
53    defining_ns: &str,
54    ns_part: Option<&str>,
55    name: &str,
56    commit: &str,
57) -> EvalResult {
58    // 1. Base namespace: resolve alias if qualified, else the defining ns.
59    //    Strip any existing `@hash` so an explicit version always wins (e.g.
60    //    a qualified self-reference inside an already-versioned namespace).
61    let base_ns: Arc<str> = match ns_part {
62        Some(p) => {
63            let resolved = globals
64                .resolve_alias(defining_ns, p)
65                .unwrap_or_else(|| Arc::from(p));
66            Arc::from(base_ns_name(&resolved))
67        }
68        None => Arc::from(base_ns_name(defining_ns)),
69    };
70    let versioned_ns: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
71
72    // 2. Fast path: binding already present in the versioned namespace.
73    //    This also serves lookups made *while* the namespace is being loaded
74    //    on this thread (defs intern sequentially during load).
75    if let Some(val) = globals.lookup_in_ns(&versioned_ns, name) {
76        return Ok(val);
77    }
78
79    // 3. Cached native fallback from a previous resolution.
80    if let Some(cached) = globals.get_cached_versioned(&base_ns, name, commit) {
81        return Ok(cached);
82    }
83
84    // 4. Load the versioned namespace if we have any source for it.  When no
85    //    source exists at all (pure-Rust namespace), go straight to the
86    //    native fallback; load *failures* (bad commit, signature rejection,
87    //    parse error) propagate rather than being masked by the fallback.
88    if !globals.is_loaded(&versioned_ns) {
89        if !versioned_source_available(globals, &base_ns, &versioned_ns) {
90            // In an AOT binary a missing embedded source most likely means
91            // the pin was not visible at compile time — make the fallback's
92            // failure say so instead of a bare "unbound symbol".
93            return pinned_native_or_head_fallback(globals, &base_ns, &versioned_ns, name, commit)
94                .map_err(|e| {
95                    if globals.versioned_offline() {
96                        EvalError::Runtime(format!(
97                            "versioned namespace {versioned_ns} was not embedded at compile \
98                             time; AOT binaries cannot fetch from git at runtime ({e})"
99                        ))
100                    } else {
101                        e
102                    }
103                });
104        }
105        ensure_versioned_ns_loaded(globals, &base_ns, commit)?;
106    }
107
108    if let Some(val) = globals.lookup_in_ns(&versioned_ns, name) {
109        return Ok(val);
110    }
111
112    // 5. The historical source exists but does not define `name`: the var may
113    //    be backed by a native Rust function rather than Clojure source.
114    pinned_native_or_head_fallback(globals, &base_ns, &versioned_ns, name, commit)
115}
116
117/// Resolve a pinned native symbol: first through the opt-in pinned-native
118/// package loader (`:rust/load :dylib`, installed by the CLI), then
119/// through the verified HEAD binding.
120///
121/// When the loader reports it registered the package at the pinned commit,
122/// the symbol is looked up in the versioned namespace and the HEAD fallback
123/// is *not* consulted — a pinned package that doesn't define the symbol is
124/// an error.
125fn pinned_native_or_head_fallback(
126    globals: &Arc<GlobalEnv>,
127    base_ns: &str,
128    versioned_ns: &str,
129    name: &str,
130    commit: &str,
131) -> EvalResult {
132    let loader = globals.pinned_native_loader.read().unwrap().clone();
133    if let Some(loader) = loader
134        && loader(globals, base_ns, commit)?
135    {
136        return globals
137            .lookup_in_ns(versioned_ns, name)
138            .ok_or_else(|| EvalError::UnboundSymbol(format!("{versioned_ns}/{name}")));
139    }
140    native_head_fallback(globals, base_ns, name, commit)
141}
142
143/// Pin `base_ns@commit` if any source for it is locatable, returning whether
144/// it was loaded.
145///
146/// Used by the AOT compiler's discovery pass: every versioned symbol found in
147/// the program is force-loaded at compile time so its source lands in
148/// `GlobalEnv::versioned_sources` for embedding.  Namespaces with no
149/// locatable Clojure source (pure-Rust packages, or quoted symbols that
150/// merely look versioned) are skipped — their resolution is a runtime
151/// concern.  Genuine load failures (missing commit, signature rejection,
152/// parse error) propagate.
153pub fn pin_if_available(globals: &Arc<GlobalEnv>, base_ns: &str, commit: &str) -> EvalResult<bool> {
154    let versioned_ns = format!("{base_ns}@{commit}");
155    if !versioned_source_available(globals, base_ns, &versioned_ns) {
156        return Ok(false);
157    }
158    ensure_versioned_ns_loaded(globals, base_ns, commit)?;
159    Ok(true)
160}
161
162/// True if source for `base_ns` at some commit could be obtained: either an
163/// embedded builtin source registered under the versioned name, or a source
164/// file on the source path that lives inside a git repository.
165fn versioned_source_available(globals: &GlobalEnv, base_ns: &str, versioned_ns: &str) -> bool {
166    if globals.builtin_source(versioned_ns).is_some() {
167        return true;
168    }
169    // Offline (AOT) binaries may only use embedded sources.
170    if globals.versioned_offline() {
171        return false;
172    }
173    let rel_path = base_ns.replace('.', "/").replace('-', "_");
174    let src_paths = globals.source_paths.read().unwrap().clone();
175    match crate::env::loader::find_source_file(&rel_path, &src_paths) {
176        Some((_, file_path)) => globals
177            .vcs()
178            .and_then(|vcs| vcs.find_repo_root(Path::new(&file_path)))
179            .is_some(),
180        None => false,
181    }
182}
183
184/// Ensure the versioned namespace `"<base_ns>@<commit>"` is loaded, returning
185/// its name.
186///
187/// Source is taken from the embedded builtin registry first (AOT binaries
188/// embed pinned sources under the versioned name; embedded sources were
189/// signature-checked at compile time), falling back to fetching the file from
190/// git history.  Idempotent, with the same same-thread cycle detection and
191/// cross-thread coordination as the unversioned loader.
192pub fn ensure_versioned_ns_loaded(
193    globals: &Arc<GlobalEnv>,
194    base_ns: &str,
195    commit: &str,
196) -> EvalResult<Arc<str>> {
197    let versioned_ns_name: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
198
199    if globals.is_loaded(&versioned_ns_name) {
200        return Ok(versioned_ns_name);
201    }
202
203    let should_load = crate::env::loader::claim_or_wait(globals, &versioned_ns_name)?;
204    if !should_load {
205        return Ok(versioned_ns_name);
206    }
207
208    let result = do_versioned_load(globals, base_ns, commit, &versioned_ns_name);
209
210    globals
211        .loading
212        .lock()
213        .unwrap()
214        .remove(versioned_ns_name.as_ref());
215    if result.is_ok() {
216        globals.mark_loaded(&versioned_ns_name);
217    }
218    globals.loading_done.notify_all();
219
220    result?;
221    Ok(versioned_ns_name)
222}
223
224/// Fetch (or look up) the pinned source and evaluate it into the versioned
225/// namespace.  The caller owns the loading claim.
226fn do_versioned_load(
227    globals: &Arc<GlobalEnv>,
228    base_ns: &str,
229    commit: &str,
230    versioned_ns_name: &Arc<str>,
231) -> EvalResult<()> {
232    // Embedded source first: AOT binaries register pinned sources under the
233    // versioned namespace name.  These were fetched and (optionally)
234    // signature-verified at compile time, so the git path is skipped
235    // entirely.
236    let (src, git_location): (String, Option<(String, String)>) =
237        if let Some(builtin) = globals.builtin_source(versioned_ns_name) {
238            (builtin.to_owned(), None)
239        } else if globals.versioned_offline() {
240            return Err(EvalError::Runtime(format!(
241                "versioned namespace {versioned_ns_name} was not embedded at compile time; \
242                 AOT binaries cannot fetch from git at runtime"
243            )));
244        } else {
245            let (src, location) = fetch_versioned_source(globals, base_ns, commit)?;
246            (src, Some(location))
247        };
248
249    // Create the versioned namespace (immutable).
250    {
251        use cljrs_value::Namespace;
252        let ns = cljrs_gc::GcPtr::new(Namespace::new_versioned(versioned_ns_name.as_ref()));
253        if let Some((ref file_path, ref repo_root)) = git_location {
254            ns.get().set_source_location(file_path, Some(repo_root));
255        }
256        let mut map = globals.namespaces.write().unwrap();
257        map.entry(versioned_ns_name.clone()).or_insert(ns);
258    }
259
260    // Pre-refer clojure.core.
261    globals.refer_all(versioned_ns_name, "clojure.core");
262
263    // Evaluate all forms with a versioned commit context so that
264    // same-namespace calls inside the historical source also resolve at
265    // `commit` rather than HEAD.
266    let saved_ns = globals
267        .lookup_var("clojure.core", "*ns*")
268        .and_then(|v| crate::env::dynamics::deref_var(&v));
269    {
270        let mut env = Env::new_versioned(globals.clone(), versioned_ns_name, commit);
271        let file_label = format!("<{base_ns}@{commit}>");
272        let mut parser = cljrs_reader::Parser::new(src, file_label);
273        let forms = parser.parse_all().map_err(EvalError::Read)?;
274        for form in forms {
275            let _alloc_frame = cljrs_gc::push_alloc_frame();
276            globals
277                .eval(&form, &mut env)
278                .map_err(|e| crate::env::loader::annotate(e, versioned_ns_name))?;
279        }
280    }
281    if let Some(saved) = saved_ns
282        && let Some(var) = globals.lookup_var("clojure.core", "*ns*")
283    {
284        var.get().bind(saved);
285    }
286
287    Ok(())
288}
289
290/// Fetch the source of `base_ns` at `commit` from git history, returning
291/// `(source_text, (file_path, repo_root))`.  Records the fetched text in
292/// `GlobalEnv::versioned_sources` so the AOT compiler can embed it.
293fn fetch_versioned_source(
294    globals: &Arc<GlobalEnv>,
295    base_ns: &str,
296    commit: &str,
297) -> EvalResult<(String, (String, String))> {
298    // Locate the source file for the base namespace.
299    let rel_path = base_ns.replace('.', "/").replace('-', "_");
300    let src_paths = globals.source_paths.read().unwrap().clone();
301    let (_, file_path) =
302        crate::env::loader::find_source_file(&rel_path, &src_paths).ok_or_else(|| {
303            EvalError::Runtime(format!(
304                "Cannot find source for namespace {base_ns} (needed for {base_ns}@{commit})"
305            ))
306        })?;
307
308    // Locate the git repository.  A build with no VCS provider has no git
309    // history to reach for, so it lands in the same "not in a repository" path
310    // as a source tree that genuinely is not checked in.
311    let not_in_repo = || {
312        EvalError::Runtime(format!(
313            "Namespace {base_ns} (file {file_path}) is not in a git repository; \
314             cannot resolve {base_ns}@{commit}"
315        ))
316    };
317    let vcs = globals.vcs().ok_or_else(not_in_repo)?;
318    let repo_root = vcs
319        .find_repo_root(Path::new(&file_path))
320        .ok_or_else(not_in_repo)?;
321
322    // Verify commit signature before loading any historical code.
323    globals.check_commit_signature(&repo_root.to_string_lossy(), commit)?;
324
325    // Compute the path relative to the repo root.
326    let abs_file = Path::new(&file_path);
327    let rel_file = abs_file.strip_prefix(&repo_root).map_err(|_| {
328        EvalError::Runtime(format!(
329            "Cannot compute relative path for {file_path} within {}",
330            repo_root.display()
331        ))
332    })?;
333    let rel_file_str = rel_file.to_string_lossy();
334
335    // Fetch the source at the requested commit.
336    let src = vcs
337        .file_at_commit(&repo_root, &rel_file_str, commit)
338        .map_err(EvalError::Runtime)?;
339
340    // Record for AOT embedding.
341    globals.record_versioned_source(&format!("{base_ns}@{commit}"), &src);
342
343    Ok((src, (file_path, repo_root.display().to_string())))
344}
345
346/// Fall back to the HEAD value for a native Rust function when no Clojure
347/// source definition exists for the symbol at the requested commit.
348///
349/// Native functions live in the running binary; we can't fetch and execute a
350/// historical compiled implementation, so versioned lookups of a native
351/// symbol resolve to the current implementation.
352///
353/// Returns the HEAD `NativeFunction` value (caching it under the requested
354/// commit so later lookups are fast), or a descriptive `EvalError` otherwise.
355fn native_head_fallback(
356    globals: &GlobalEnv,
357    base_ns: &str,
358    name: &str,
359    commit: &str,
360) -> EvalResult {
361    match globals.lookup_in_ns(base_ns, name) {
362        Some(val) if matches!(val, Value::NativeFunction(_)) => {
363            check_native_provenance(globals, base_ns, commit)?;
364            globals.cache_versioned(base_ns, name, commit, val.clone());
365            Ok(val)
366        }
367        Some(_) => Err(EvalError::Runtime(format!(
368            "Cannot find definition of `{name}` in `{base_ns}@{commit}`"
369        ))),
370        None => Err(EvalError::UnboundSymbol(format!("{base_ns}/{name}"))),
371    }
372}
373
374/// Verify the recorded provenance of a native package against a pinned
375/// commit ("verified HEAD binding").
376///
377/// Native functions always come from the current binary; this check makes
378/// the fallback explicit and auditable instead of silent.  The recorded and
379/// requested hashes match when either is a prefix of the other (either side
380/// may be abbreviated).  Mismatching or missing provenance warns once per
381/// `ns@commit` by default, and is an error when
382/// `GlobalEnv::enforce_native_versions` is set.
383fn check_native_provenance(globals: &GlobalEnv, base_ns: &str, commit: &str) -> EvalResult<()> {
384    let recorded = globals.native_provenance_for(base_ns);
385    if let Some(ref rec) = recorded {
386        let matches = rec.starts_with(commit) || commit.starts_with(rec.as_ref());
387        if matches {
388            return Ok(());
389        }
390    }
391
392    let described = match &recorded {
393        Some(rec) => format!("is built from commit {rec}"),
394        None => "has no recorded provenance".to_string(),
395    };
396    if globals.enforce_native_versions() {
397        return Err(EvalError::Runtime(format!(
398            "native package `{base_ns}` {described}; cannot satisfy pinned \
399             `{base_ns}@{commit}` (native functions always come from the current binary)"
400        )));
401    }
402
403    let warn_key: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
404    if globals.provenance_warned.lock().unwrap().insert(warn_key) {
405        eprintln!(
406            "cljrs: warning: native package `{base_ns}` {described}; pinned \
407             `{base_ns}@{commit}` resolves to the current binary's implementation \
408             (use --enforce-native-versions to make this an error)"
409        );
410    }
411    Ok(())
412}
413
414// ── Tests ─────────────────────────────────────────────────────────────────────
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    /// In offline (AOT) mode a versioned namespace with no embedded source
421    /// fails with the clear "not embedded at compile time" error — never a
422    /// git fetch.
423    #[test]
424    fn offline_load_without_embedded_source_errors() {
425        let _mutator = cljrs_gc::register_mutator();
426        let globals = GlobalEnv::new(crate::ExecutionMode::TreeWalk);
427        globals.set_versioned_offline(true);
428
429        let err = ensure_versioned_ns_loaded(&globals, "mylib", "abc1234abcdef")
430            .expect_err("offline load must fail without an embedded source");
431        let msg = format!("{err:?}");
432        assert!(
433            msg.contains("was not embedded at compile time"),
434            "unexpected error: {msg}"
435        );
436    }
437
438    /// Embedded sources satisfy offline mode (the AOT binary path).
439    #[test]
440    fn offline_load_with_embedded_source_succeeds() {
441        let _mutator = cljrs_gc::register_mutator();
442        let globals = GlobalEnv::new(crate::ExecutionMode::TreeWalk);
443        globals.set_versioned_offline(true);
444        globals.register_builtin_source("mylib@abc1234abcdef", "(def x 1)");
445
446        let ns = ensure_versioned_ns_loaded(&globals, "mylib", "abc1234abcdef")
447            .expect("embedded source must load offline");
448        assert_eq!(ns.as_ref(), "mylib@abc1234abcdef");
449        assert!(globals.is_loaded("mylib@abc1234abcdef"));
450    }
451}