cljrs-runtime 0.1.251

clojurust runtime: environment, builtins, tree-walking interpreter, and tiered evaluation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
//! Shared versioned-symbol/namespace resolution service.
//!
//! This is the single implementation used by every execution tier — the
//! tree-walking interpreter, the IR interpreter, JIT-compiled code (via the
//! `rt_load_global*` runtime bridges), and AOT binaries.
//!
//! ## Model
//!
//! Resolving `ns/name@commit` means: ensure the **versioned namespace**
//! `"ns@commit"` is loaded (lazily, from an embedded builtin source or from
//! git history), then perform a plain `lookup_in_ns("ns@commit", name)`.
//! Versioned namespaces are immutable once loaded, so a resolved value never
//! changes for the lifetime of the process.
//!
//! ## Native (Rust-backed) functions
//!
//! Native functions registered via `cljrs-interop::Registry` have no Clojure
//! source to fetch, and the binary is a single unit at runtime — we can't
//! "go back in time" to a previous compiled implementation.  The current
//! contract is: versioned lookups of a native symbol resolve to the HEAD
//! (current) implementation regardless of the requested commit (see
//! `native_head_fallback`).

use std::path::Path;
use std::sync::Arc;

use crate::env::env::{Env, GlobalEnv};
use crate::env::error::{EvalError, EvalResult};
use cljrs_value::Value;

/// Strip a trailing `@<commit>` suffix from a namespace name, returning the
/// base namespace.  `"my.lib@abc1234"` → `"my.lib"`; names without a valid
/// commit-hash suffix are returned unchanged.
pub fn base_ns_name(ns: &str) -> &str {
    cljrs_value::symbol::split_version(ns).0
}

/// Resolve the value of `name` pinned at `commit`.
///
/// - `defining_ns` — the namespace the reference appears in (used for alias
///   resolution and for unqualified symbols).
/// - `ns_part` — the symbol's namespace qualifier, if any (may be an alias).
///
/// Resolution order:
/// 1. Determine the base namespace (alias-resolved, `@`-suffix stripped).
/// 2. Fast path: the versioned namespace `"base@commit"` already has the
///    binding (it is loaded, or is being loaded right now by this thread).
/// 3. Version-cache hit (caches native HEAD fallbacks).
/// 4. Load the versioned namespace (embedded source or git), then look up.
/// 5. Fall back to the HEAD native binding when no Clojure source exists.
pub fn resolve_versioned_value(
    globals: &Arc<GlobalEnv>,
    defining_ns: &str,
    ns_part: Option<&str>,
    name: &str,
    commit: &str,
) -> EvalResult {
    // 1. Base namespace: resolve alias if qualified, else the defining ns.
    //    Strip any existing `@hash` so an explicit version always wins (e.g.
    //    a qualified self-reference inside an already-versioned namespace).
    let base_ns: Arc<str> = match ns_part {
        Some(p) => {
            let resolved = globals
                .resolve_alias(defining_ns, p)
                .unwrap_or_else(|| Arc::from(p));
            Arc::from(base_ns_name(&resolved))
        }
        None => Arc::from(base_ns_name(defining_ns)),
    };
    let versioned_ns: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));

    // 2. Fast path: binding already present in the versioned namespace.
    //    This also serves lookups made *while* the namespace is being loaded
    //    on this thread (defs intern sequentially during load).
    if let Some(val) = globals.lookup_in_ns(&versioned_ns, name) {
        return Ok(val);
    }

    // 3. Cached native fallback from a previous resolution.
    if let Some(cached) = globals.get_cached_versioned(&base_ns, name, commit) {
        return Ok(cached);
    }

    // 4. Load the versioned namespace if we have any source for it.  When no
    //    source exists at all (pure-Rust namespace), go straight to the
    //    native fallback; load *failures* (bad commit, signature rejection,
    //    parse error) propagate rather than being masked by the fallback.
    if !globals.is_loaded(&versioned_ns) {
        if !versioned_source_available(globals, &base_ns, &versioned_ns) {
            // In an AOT binary a missing embedded source most likely means
            // the pin was not visible at compile time — make the fallback's
            // failure say so instead of a bare "unbound symbol".
            return pinned_native_or_head_fallback(globals, &base_ns, &versioned_ns, name, commit)
                .map_err(|e| {
                    if globals.versioned_offline() {
                        EvalError::Runtime(format!(
                            "versioned namespace {versioned_ns} was not embedded at compile \
                             time; AOT binaries cannot fetch from git at runtime ({e})"
                        ))
                    } else {
                        e
                    }
                });
        }
        ensure_versioned_ns_loaded(globals, &base_ns, commit)?;
    }

    if let Some(val) = globals.lookup_in_ns(&versioned_ns, name) {
        return Ok(val);
    }

    // 5. The historical source exists but does not define `name`: the var may
    //    be backed by a native Rust function rather than Clojure source.
    pinned_native_or_head_fallback(globals, &base_ns, &versioned_ns, name, commit)
}

/// Resolve a pinned native symbol: first through the opt-in pinned-native
/// package loader (`:rust/load :dylib`, installed by the CLI), then
/// through the verified HEAD binding.
///
/// When the loader reports it registered the package at the pinned commit,
/// the symbol is looked up in the versioned namespace and the HEAD fallback
/// is *not* consulted — a pinned package that doesn't define the symbol is
/// an error.
fn pinned_native_or_head_fallback(
    globals: &Arc<GlobalEnv>,
    base_ns: &str,
    versioned_ns: &str,
    name: &str,
    commit: &str,
) -> EvalResult {
    let loader = globals.pinned_native_loader.read().unwrap().clone();
    if let Some(loader) = loader
        && loader(globals, base_ns, commit)?
    {
        return globals
            .lookup_in_ns(versioned_ns, name)
            .ok_or_else(|| EvalError::UnboundSymbol(format!("{versioned_ns}/{name}")));
    }
    native_head_fallback(globals, base_ns, name, commit)
}

/// Pin `base_ns@commit` if any source for it is locatable, returning whether
/// it was loaded.
///
/// Used by the AOT compiler's discovery pass: every versioned symbol found in
/// the program is force-loaded at compile time so its source lands in
/// `GlobalEnv::versioned_sources` for embedding.  Namespaces with no
/// locatable Clojure source (pure-Rust packages, or quoted symbols that
/// merely look versioned) are skipped — their resolution is a runtime
/// concern.  Genuine load failures (missing commit, signature rejection,
/// parse error) propagate.
pub fn pin_if_available(globals: &Arc<GlobalEnv>, base_ns: &str, commit: &str) -> EvalResult<bool> {
    let versioned_ns = format!("{base_ns}@{commit}");
    if !versioned_source_available(globals, base_ns, &versioned_ns) {
        return Ok(false);
    }
    ensure_versioned_ns_loaded(globals, base_ns, commit)?;
    Ok(true)
}

/// True if source for `base_ns` at some commit could be obtained: either an
/// embedded builtin source registered under the versioned name, or a source
/// file on the source path that lives inside a git repository.
fn versioned_source_available(globals: &GlobalEnv, base_ns: &str, versioned_ns: &str) -> bool {
    if globals.builtin_source(versioned_ns).is_some() {
        return true;
    }
    // Offline (AOT) binaries may only use embedded sources.
    if globals.versioned_offline() {
        return false;
    }
    let rel_path = base_ns.replace('.', "/").replace('-', "_");
    let src_paths = globals.source_paths.read().unwrap().clone();
    match crate::env::loader::find_source_file(&rel_path, &src_paths) {
        Some((_, file_path)) => globals
            .vcs()
            .and_then(|vcs| vcs.find_repo_root(Path::new(&file_path)))
            .is_some(),
        None => false,
    }
}

/// Ensure the versioned namespace `"<base_ns>@<commit>"` is loaded, returning
/// its name.
///
/// Source is taken from the embedded builtin registry first (AOT binaries
/// embed pinned sources under the versioned name; embedded sources were
/// signature-checked at compile time), falling back to fetching the file from
/// git history.  Idempotent, with the same same-thread cycle detection and
/// cross-thread coordination as the unversioned loader.
pub fn ensure_versioned_ns_loaded(
    globals: &Arc<GlobalEnv>,
    base_ns: &str,
    commit: &str,
) -> EvalResult<Arc<str>> {
    let versioned_ns_name: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));

    if globals.is_loaded(&versioned_ns_name) {
        return Ok(versioned_ns_name);
    }

    let should_load = crate::env::loader::claim_or_wait(globals, &versioned_ns_name)?;
    if !should_load {
        return Ok(versioned_ns_name);
    }

    let result = do_versioned_load(globals, base_ns, commit, &versioned_ns_name);

    globals
        .loading
        .lock()
        .unwrap()
        .remove(versioned_ns_name.as_ref());
    if result.is_ok() {
        globals.mark_loaded(&versioned_ns_name);
    }
    globals.loading_done.notify_all();

    result?;
    Ok(versioned_ns_name)
}

/// Fetch (or look up) the pinned source and evaluate it into the versioned
/// namespace.  The caller owns the loading claim.
fn do_versioned_load(
    globals: &Arc<GlobalEnv>,
    base_ns: &str,
    commit: &str,
    versioned_ns_name: &Arc<str>,
) -> EvalResult<()> {
    // Embedded source first: AOT binaries register pinned sources under the
    // versioned namespace name.  These were fetched and (optionally)
    // signature-verified at compile time, so the git path is skipped
    // entirely.
    let (src, git_location): (String, Option<(String, String)>) =
        if let Some(builtin) = globals.builtin_source(versioned_ns_name) {
            (builtin.to_owned(), None)
        } else if globals.versioned_offline() {
            return Err(EvalError::Runtime(format!(
                "versioned namespace {versioned_ns_name} was not embedded at compile time; \
                 AOT binaries cannot fetch from git at runtime"
            )));
        } else {
            let (src, location) = fetch_versioned_source(globals, base_ns, commit)?;
            (src, Some(location))
        };

    // Create the versioned namespace (immutable).
    {
        use cljrs_value::Namespace;
        let ns = cljrs_gc::GcPtr::new(Namespace::new_versioned(versioned_ns_name.as_ref()));
        if let Some((ref file_path, ref repo_root)) = git_location {
            ns.get().set_source_location(file_path, Some(repo_root));
        }
        let mut map = globals.namespaces.write().unwrap();
        map.entry(versioned_ns_name.clone()).or_insert(ns);
    }

    // Pre-refer clojure.core.
    globals.refer_all(versioned_ns_name, "clojure.core");

    // Evaluate all forms with a versioned commit context so that
    // same-namespace calls inside the historical source also resolve at
    // `commit` rather than HEAD.
    let saved_ns = globals
        .lookup_var("clojure.core", "*ns*")
        .and_then(|v| crate::env::dynamics::deref_var(&v));
    {
        let mut env = Env::new_versioned(globals.clone(), versioned_ns_name, commit);
        let file_label = format!("<{base_ns}@{commit}>");
        let mut parser = cljrs_reader::Parser::new(src, file_label);
        let forms = parser.parse_all().map_err(EvalError::Read)?;
        for form in forms {
            let _alloc_frame = cljrs_gc::push_alloc_frame();
            globals
                .eval(&form, &mut env)
                .map_err(|e| crate::env::loader::annotate(e, versioned_ns_name))?;
        }
    }
    if let Some(saved) = saved_ns
        && let Some(var) = globals.lookup_var("clojure.core", "*ns*")
    {
        var.get().bind(saved);
    }

    Ok(())
}

/// Fetch the source of `base_ns` at `commit` from git history, returning
/// `(source_text, (file_path, repo_root))`.  Records the fetched text in
/// `GlobalEnv::versioned_sources` so the AOT compiler can embed it.
fn fetch_versioned_source(
    globals: &Arc<GlobalEnv>,
    base_ns: &str,
    commit: &str,
) -> EvalResult<(String, (String, String))> {
    // Locate the source file for the base namespace.
    let rel_path = base_ns.replace('.', "/").replace('-', "_");
    let src_paths = globals.source_paths.read().unwrap().clone();
    let (_, file_path) =
        crate::env::loader::find_source_file(&rel_path, &src_paths).ok_or_else(|| {
            EvalError::Runtime(format!(
                "Cannot find source for namespace {base_ns} (needed for {base_ns}@{commit})"
            ))
        })?;

    // Locate the git repository.  A build with no VCS provider has no git
    // history to reach for, so it lands in the same "not in a repository" path
    // as a source tree that genuinely is not checked in.
    let not_in_repo = || {
        EvalError::Runtime(format!(
            "Namespace {base_ns} (file {file_path}) is not in a git repository; \
             cannot resolve {base_ns}@{commit}"
        ))
    };
    let vcs = globals.vcs().ok_or_else(not_in_repo)?;
    let repo_root = vcs
        .find_repo_root(Path::new(&file_path))
        .ok_or_else(not_in_repo)?;

    // Verify commit signature before loading any historical code.
    globals.check_commit_signature(&repo_root.to_string_lossy(), commit)?;

    // Compute the path relative to the repo root.
    let abs_file = Path::new(&file_path);
    let rel_file = abs_file.strip_prefix(&repo_root).map_err(|_| {
        EvalError::Runtime(format!(
            "Cannot compute relative path for {file_path} within {}",
            repo_root.display()
        ))
    })?;
    let rel_file_str = rel_file.to_string_lossy();

    // Fetch the source at the requested commit.
    let src = vcs
        .file_at_commit(&repo_root, &rel_file_str, commit)
        .map_err(EvalError::Runtime)?;

    // Record for AOT embedding.
    globals.record_versioned_source(&format!("{base_ns}@{commit}"), &src);

    Ok((src, (file_path, repo_root.display().to_string())))
}

/// Fall back to the HEAD value for a native Rust function when no Clojure
/// source definition exists for the symbol at the requested commit.
///
/// Native functions live in the running binary; we can't fetch and execute a
/// historical compiled implementation, so versioned lookups of a native
/// symbol resolve to the current implementation.
///
/// Returns the HEAD `NativeFunction` value (caching it under the requested
/// commit so later lookups are fast), or a descriptive `EvalError` otherwise.
fn native_head_fallback(
    globals: &GlobalEnv,
    base_ns: &str,
    name: &str,
    commit: &str,
) -> EvalResult {
    match globals.lookup_in_ns(base_ns, name) {
        Some(val) if matches!(val, Value::NativeFunction(_)) => {
            check_native_provenance(globals, base_ns, commit)?;
            globals.cache_versioned(base_ns, name, commit, val.clone());
            Ok(val)
        }
        Some(_) => Err(EvalError::Runtime(format!(
            "Cannot find definition of `{name}` in `{base_ns}@{commit}`"
        ))),
        None => Err(EvalError::UnboundSymbol(format!("{base_ns}/{name}"))),
    }
}

/// Verify the recorded provenance of a native package against a pinned
/// commit ("verified HEAD binding").
///
/// Native functions always come from the current binary; this check makes
/// the fallback explicit and auditable instead of silent.  The recorded and
/// requested hashes match when either is a prefix of the other (either side
/// may be abbreviated).  Mismatching or missing provenance warns once per
/// `ns@commit` by default, and is an error when
/// `GlobalEnv::enforce_native_versions` is set.
fn check_native_provenance(globals: &GlobalEnv, base_ns: &str, commit: &str) -> EvalResult<()> {
    let recorded = globals.native_provenance_for(base_ns);
    if let Some(ref rec) = recorded {
        let matches = rec.starts_with(commit) || commit.starts_with(rec.as_ref());
        if matches {
            return Ok(());
        }
    }

    let described = match &recorded {
        Some(rec) => format!("is built from commit {rec}"),
        None => "has no recorded provenance".to_string(),
    };
    if globals.enforce_native_versions() {
        return Err(EvalError::Runtime(format!(
            "native package `{base_ns}` {described}; cannot satisfy pinned \
             `{base_ns}@{commit}` (native functions always come from the current binary)"
        )));
    }

    let warn_key: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
    if globals.provenance_warned.lock().unwrap().insert(warn_key) {
        eprintln!(
            "cljrs: warning: native package `{base_ns}` {described}; pinned \
             `{base_ns}@{commit}` resolves to the current binary's implementation \
             (use --enforce-native-versions to make this an error)"
        );
    }
    Ok(())
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    /// In offline (AOT) mode a versioned namespace with no embedded source
    /// fails with the clear "not embedded at compile time" error — never a
    /// git fetch.
    #[test]
    fn offline_load_without_embedded_source_errors() {
        let _mutator = cljrs_gc::register_mutator();
        let globals = GlobalEnv::new(crate::ExecutionMode::TreeWalk);
        globals.set_versioned_offline(true);

        let err = ensure_versioned_ns_loaded(&globals, "mylib", "abc1234abcdef")
            .expect_err("offline load must fail without an embedded source");
        let msg = format!("{err:?}");
        assert!(
            msg.contains("was not embedded at compile time"),
            "unexpected error: {msg}"
        );
    }

    /// Embedded sources satisfy offline mode (the AOT binary path).
    #[test]
    fn offline_load_with_embedded_source_succeeds() {
        let _mutator = cljrs_gc::register_mutator();
        let globals = GlobalEnv::new(crate::ExecutionMode::TreeWalk);
        globals.set_versioned_offline(true);
        globals.register_builtin_source("mylib@abc1234abcdef", "(def x 1)");

        let ns = ensure_versioned_ns_loaded(&globals, "mylib", "abc1234abcdef")
            .expect("embedded source must load offline");
        assert_eq!(ns.as_ref(), "mylib@abc1234abcdef");
        assert!(globals.is_loaded("mylib@abc1234abcdef"));
    }
}