on master help sign in
Improve history, snapshot, and evaluator performance
commit356c644b757df4414bbd1dac73661690a56b720c authorMontelot <montelot-dev@typeable.io> dateSat 10 Oct 2026, 15:58 (3 days ago) parent7d45adf
16 files changed+1081−214
config.j+4−2 docs/base.md+17 specs/main.md+2 src/builtins.rs+16−5 src/config.rs+23−4 src/domain.rs+6 src/eval.rs+89−71 src/jj.rs+301−39 src/lib.rs+1 src/lookup.rs+78 src/render.rs+110−92 src/repo.rs+38−1 src/value.rs+21 tests/eval.rs+89 tests/performance_lookup.rs+255 tests/tree.rs+31
config.j+4−2
@@ -148,6 +148,8 @@ touchedPaths : Change -> [Path] -- paths whose content differs, one p
148148blob : Text -> Blob -- a resolved regular file
149149text : Blob -> Text -- its content (markers if unresolved)
150150by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
151+lookupCommit : Id -> Repo -> Commit -- indexed commit lookup without moving the focus
152+lookupContent : Path -> Snapshot -> Blob -- indexed file content; an empty blob if absent
151153subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one
152154 -- pass: what `commits` (below) is
153155meta : Id -> Meta -- git hash, author, email, committer time;
@@ -362,7 +364,7 @@ goto = \rs repo ->
362364 else crash ("expected one revision, got " ++ show (length is))
363365
364366commitAt : Id -> Repo -> Commit
365−commitAt = \i repo -> (by i repo).root
367+commitAt = lookupCommit
366368
367369prev : Edit -- the parent
368370prev = goto parents
@@ -535,7 +537,7 @@ entryAt = \p s -> filter (\e -> e.path == p) s
535537
536538-- The content at path p, or an empty file if absent.
537539contentAt : Path -> Snapshot -> Blob
538−contentAt = \p s -> (head (entryAt p s)).content or blob ""
540+contentAt = lookupContent
539541
540542-- The paths a change touches.
541543touched : Change -> [Path]
docs/base.md+17
@@ -810,6 +810,9 @@ goto parents -- == prev
810810```
811811
812812**`commitAt : Id -> Repo -> Commit`** — the commit with a given id.
813+The reference definition uses `lookupCommit`, which indexes the repository
814+once and retrieves commits without moving the focus. Its result is the same
815+as `(by i repo).root`.
813816
814817**`prev : Edit`** — to the parent (`goto parents`).
815818
@@ -1045,6 +1048,20 @@ j 'files' -- the working-copy commit's files
10451048
10461049**`contentAt : Path -> Snapshot -> Blob`** — the content at a path, or an
10471050empty file if absent.
1051+The reference definition uses `lookupContent`, which shares a path index
1052+between repeated lookups in each snapshot. Duplicate paths still select the
1053+first entry, as `head (entryAt p s)` does.
1054+
1055+Existing user configurations keep working with their original definitions.
1056+To enable both indexed lookups, add these builtin declarations to your config:
1057+
1058+```
1059+lookupCommit : Id -> Repo -> Commit
1060+lookupContent : Path -> Snapshot -> Blob
1061+```
1062+
1063+Then replace the bodies of `commitAt` and `contentAt` with `lookupCommit` and
1064+`lookupContent`, respectively, keeping their existing signatures.
10481065
10491066```
10501067contentAt ./a [({ path = ./a, content = blob "x" })]
specs/main.md+2
@@ -497,6 +497,8 @@ as in §4.13.
497497| `blob t` | a resolved regular-file blob with content `t` |
498498| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
499499| `by i repo` | the repo refocused on the commit with id `i`; crashes if absent. Backed by the change-id index; its reference definition is in §10 |
500+| `lookupCommit i repo` | `(by i repo).root`, indexed once per repository value without constructing a location for each lookup. Duplicate ids select the first preorder match; malformed trees retain `by`'s traversal behavior |
501+| `lookupContent p s` | `(head (filter (\e -> e.path == p) s)).content or blob ""`, with an index for ordinary paths. The full filter's errors and first-match behavior are preserved; the reference `contentAt` uses this builtin |
500502| `subtreeCommits t` | every commit of the tree `t` (anything with `root` and `children`) in preorder, in time linear in its size: the value of `t.root :: (concat (map subtreeCommits t.children) or [])`. So it crashes only if `t` has no `root`, and a node whose `children` is not a list of values that have a `root` contributes only its own. The reference `commits` is this builtin (§9) |
501503| `diff a b` | a unified diff from the text of `a` to the text of `b`, split into lines after each `\n` (not at a lone `\r`), no `---`/`+++` header lines, three lines of context, each hunk headed `@@ -s,n +s,n @@` with the first line and the number of lines it shows of each side, as `diff -u` writes it, a line that has no trailing newline followed by `\ No newline at end of file`; `""` if equal; crashes if either is not UTF-8 |
502504| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`; §7.10. Crashes if `difft` is not on `PATH` |
src/builtins.rs+16−5
@@ -99,6 +99,8 @@ pub fn all_builtins() -> Vec<(String, Value)> {
9999 builtin("blob", 1, b_blob),
100100 builtin("text", 1, b_text),
101101 builtin("by", 2, b_by),
102+ builtin("lookupCommit", 2, b_lookup_commit),
103+ builtin("lookupContent", 2, b_lookup_content),
102104 builtin("meta", 1, b_meta),
103105 builtin("validate", 1, b_validate),
104106 builtin("diff", 2, b_diff),
@@ -453,14 +455,11 @@ fn b_split_on(_i: &mut Interp, args: &[Value]) -> BResult {
453455// ----------------------------------------------------------------------
454456
455457fn b_replay(i: &mut Interp, args: &[Value]) -> BResult {
456− let onto = args[0].as_list()?;
458+ args[0].as_list()?;
457459 let ch = &args[1];
458460 let from = ch.field("from")?;
459461 let to = ch.field("to")?;
460− let from = from.as_list()?;
461− let to = to.as_list()?;
462− let out = i.backend.replay(onto, from, to)?;
463− Ok(Value::list(out))
462+ i.backend.replay_snapshot(&args[0], &from, &to)
464463}
465464
466465fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -563,6 +562,18 @@ fn b_by(_i: &mut Interp, args: &[Value]) -> BResult {
563562 }
564563}
565564
565+fn b_lookup_commit(_i: &mut Interp, args: &[Value]) -> BResult {
566+ let id = match &args[0] {
567+ Value::Id(id) => id.as_str(),
568+ v => return Err(Crash::new(format!("by: expected an Id, got a {}", v.kind_name()))),
569+ };
570+ crate::repo::commit_by_id(&args[1], id)?.ok_or_else(|| Crash::new("by: no such commit"))
571+}
572+
573+fn b_lookup_content(_i: &mut Interp, args: &[Value]) -> BResult {
574+ crate::lookup::content_at(&args[0], &args[1])
575+}
576+
566577fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
567578 let id = match &args[0] {
568579 Value::Id(id) => id.to_string(),
src/config.rs+23−4
@@ -40,7 +40,7 @@ const RESERVED_BUILTINS: &[&str] = &[
4040 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
4141 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
4242 "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths", "subtreeCommits",
43+ "extract", "touchedPaths", "subtreeCommits", "lookupCommit", "lookupContent",
4444];
4545
4646pub fn is_reserved_builtin(name: &str) -> bool {
@@ -312,8 +312,15 @@ fn components(edges: &[Vec<usize>]) -> Vec<Vec<usize>> {
312312 out
313313}
314314
315−/// Evaluation order of the bindings of a `let` block (§4.1), as indices into
316−/// them, or `None` when references outside lambdas form a cycle.
315+pub(crate) struct LetPlan {
316+ pub order: Vec<usize>,
317+ /// Whether a closure needs to see its own or a later binding. Otherwise
318+ /// completed bindings can be held in immutable, acyclic environments.
319+ pub recursive: bool,
320+}
321+
322+/// Plan the bindings of a `let` block (§4.1), or return `None` when references
323+/// outside lambdas form a cycle.
317324///
318325/// The rule is `dependency_order`'s, with ready bindings taken in source
319326/// order: a binding follows every binding it refers to outside a lambda; the
@@ -323,7 +330,7 @@ fn components(edges: &[Vec<usize>]) -> Vec<Vec<usize>> {
323330/// the source order when every reference is to an earlier binding, or from
324331/// inside a lambda to its own, as in nearly every block, which is so taken
325332/// without the sorting: this runs each time a block is evaluated.
326−pub(crate) fn let_order(bs: &[(String, Rc<Expr>)]) -> Option<Vec<usize>> {
333+pub(crate) fn let_plan(bs: &[(String, Rc<Expr>)]) -> Option<LetPlan> {
327334 let refs: Vec<Vec<(usize, bool)>> = bs
328335 .iter()
329336 .map(|(_, e)| {
@@ -332,6 +339,18 @@ pub(crate) fn let_order(bs: &[(String, Rc<Expr>)]) -> Option<Vec<usize>> {
332339 out
333340 })
334341 .collect();
342+ let order = let_order(bs, &refs)?;
343+ let mut position = vec![0; bs.len()];
344+ for (p, &i) in order.iter().enumerate() {
345+ position[i] = p;
346+ }
347+ let recursive = refs.iter().enumerate().any(|(i, refs)| {
348+ refs.iter().any(|&(j, _)| position[j] >= position[i])
349+ });
350+ Some(LetPlan { order, recursive })
351+}
352+
353+fn let_order(bs: &[(String, Rc<Expr>)], refs: &[Vec<(usize, bool)>]) -> Option<Vec<usize>> {
335354 let in_order =
336355 |i: usize, r: &[(usize, bool)]| r.iter().all(|&(j, outside)| j < i || (j == i && !outside));
337356 if refs.iter().enumerate().all(|(i, r)| in_order(i, r)) {
src/domain.rs+6
@@ -82,6 +82,12 @@ pub trait Backend {
8282 to: &[Value],
8383 ) -> Result<Vec<Value>, Crash>;
8484
85+ /// Keep snapshot identity available to backends that can reuse stored
86+ /// trees. Backends without a tree cache retain their slice-based merge.
87+ fn replay_snapshot(&self, onto: &Value, from: &Value, to: &Value) -> Result<Value, Crash> {
88+ Ok(Value::list(self.replay(onto.as_list()?, from.as_list()?, to.as_list()?)?))
89+ }
90+
8591 /// shortest prefix of `id` unique among visible ids, min length 4
8692 fn unique_prefix(&self, id: &str) -> String {
8793 let others: Vec<String> = self.visible_ids();
src/eval.rs+89−71
@@ -94,11 +94,12 @@ pub(crate) enum Cont {
9494 Arg(Rc<Expr>, Env, Rc<Cont>),
9595 Fun(Value, Rc<Cont>),
9696 If(Rc<Expr>, Rc<Expr>, Env, Rc<Cont>),
97− /// the bindings of a `let` block, in evaluation order, whose values so
98− /// far are in the block's frame
97+ /// A block's bindings in evaluation order. Only recursive blocks need a
98+ /// cell; acyclic ones extend the environment after each value is ready.
9999 LetRest {
100− exprs: Vec<Rc<Expr>>,
101− cell: Rc<RefCell<Vec<Value>>>,
100+ bindings: Rc<[(String, Rc<Expr>)]>,
101+ at: usize,
102+ cell: Option<Rc<RefCell<Vec<Value>>>>,
102103 body: Rc<Expr>,
103104 env: Env,
104105 cont: Rc<Cont>,
@@ -122,25 +123,26 @@ pub(crate) enum Cont {
122123 /// apply the returned function to this argument: the `labelled "name"`
123124 /// of a Labelled to the real arg
124125 ApplyTo(Value, Rc<Cont>),
125− UpdateBase(Vec<(String, Rc<Expr>)>, Env, Rc<Cont>),
126+ // Literal/update inputs and accumulators are shared across continuation
127+ // clones. The trampoline keeps an active clone for crash unwinding; deep
128+ // copying these buffers at every element made flat literals quadratic.
129+ UpdateBase(Rc<[(String, Rc<Expr>)]>, Env, Rc<Cont>),
126130 UpdateFields {
127131 base: Value,
128− names: Vec<String>,
129− exprs: Vec<Rc<Expr>>,
130− acc: Vec<(String, Value)>,
132+ fields: Rc<[(String, Rc<Expr>)]>,
133+ acc: Rc<RefCell<Vec<(String, Value)>>>,
131134 env: Env,
132135 cont: Rc<Cont>,
133136 },
134137 RecordFields {
135− names: Vec<String>,
136− exprs: Vec<Rc<Expr>>,
137− acc: Vec<(String, Value)>,
138+ fields: Rc<[(String, Rc<Expr>)]>,
139+ acc: Rc<RefCell<Vec<(String, Value)>>>,
138140 env: Env,
139141 cont: Rc<Cont>,
140142 },
141143 ListElems {
142− exprs: Vec<Rc<Expr>>,
143− acc: Vec<Value>,
144+ exprs: Rc<[Rc<Expr>]>,
145+ acc: Rc<RefCell<Vec<Value>>>,
144146 env: Env,
145147 cont: Rc<Cont>,
146148 },
@@ -738,20 +740,29 @@ impl Interp {
738740 }
739741 // the bindings are evaluated in dependency order, not source
740742 // order, and a cycle among them is a crash (§4.1)
741− let order = match crate::config::let_order(bs) {
742− Some(order) => order,
743+ let plan = match crate::config::let_plan(bs) {
744+ Some(plan) => plan,
743745 None => return Run::Crash(Crash::new("let: a cycle among the bindings")),
744746 };
745− let names: Rc<[String]> = order.iter().map(|&i| bs[i].0.clone()).collect();
746− let exprs: Vec<Rc<Expr>> = order.iter().map(|&i| bs[i].1.clone()).collect();
747− // one recursive frame shared by every binding and the body, so
748− // the block is mutually recursive (§4.1)
749− let (env2, cell) = env.extend_rec(names);
747+ let bindings: Rc<[(String, Rc<Expr>)]> =
748+ plan.order.iter().map(|&i| bs[i].clone()).collect();
749+ // A closure in a recursive block sees later bindings through
750+ // the shared cell (§4.1). Acyclic bindings need only earlier
751+ // values: immutable frames avoid a cell -> closure -> frame
752+ // cycle for ordinary local helper functions.
753+ let (env2, cell) = if plan.recursive {
754+ let names = bindings.iter().map(|(n, _)| n.clone()).collect();
755+ let (env, cell) = env.extend_rec(names);
756+ (env, Some(cell))
757+ } else {
758+ (env, None)
759+ };
750760 Run::Step(State::Eval(
751− exprs[0].clone(),
761+ bindings[0].1.clone(),
752762 env2.clone(),
753763 Cont::LetRest {
754− exprs,
764+ bindings,
765+ at: 0,
755766 cell,
756767 body: body.clone(),
757768 env: env2,
@@ -816,22 +827,19 @@ impl Interp {
816827 Expr::Update(base, fields) => Run::Step(State::Eval(
817828 base.clone(),
818829 env.clone(),
819− Cont::UpdateBase(fields.clone(), env, Rc::new(k)),
830+ Cont::UpdateBase(fields.clone().into(), env, Rc::new(k)),
820831 )),
821832 Expr::Record(fields) => {
822833 if fields.is_empty() {
823834 let m = crate::value::RecordMap::new();
824835 return Run::Step(State::Ret(Value::Record(Rc::new(m)), k));
825836 }
826− let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
827− let exprs: Vec<Rc<Expr>> = fields.iter().map(|(_, v)| v.clone()).collect();
828837 Run::Step(State::Eval(
829− exprs[0].clone(),
838+ fields[0].1.clone(),
830839 env.clone(),
831840 Cont::RecordFields {
832− names,
833− exprs,
834− acc: Vec::new(),
841+ fields: fields.clone().into(),
842+ acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
835843 env,
836844 cont: Rc::new(k),
837845 },
@@ -845,8 +853,8 @@ impl Interp {
845853 elems[0].clone(),
846854 env.clone(),
847855 Cont::ListElems {
848− exprs: elems.clone(),
849− acc: Vec::new(),
856+ exprs: elems.clone().into(),
857+ acc: Rc::new(RefCell::new(Vec::with_capacity(elems.len()))),
850858 env,
851859 cont: Rc::new(k),
852860 },
@@ -872,26 +880,31 @@ impl Interp {
872880 ))),
873881 },
874882 Cont::LetRest {
875− exprs,
883+ bindings,
884+ at,
876885 cell,
877886 body,
878887 env,
879888 cont,
880889 } => {
881− let filled = {
882− let mut values = cell.borrow_mut();
883− values.push(v);
884− values.len()
890+ let env = match &cell {
891+ Some(cell) => {
892+ cell.borrow_mut().push(v);
893+ env
894+ }
895+ None => env.extend(vec![(bindings[at].0.clone(), v)]),
885896 };
886− if filled == exprs.len() {
897+ let at = at + 1;
898+ if at == bindings.len() {
887899 Run::Step(State::Eval(body, env, (*cont).clone()))
888900 } else {
889− let next = exprs[filled].clone();
901+ let next = bindings[at].1.clone();
890902 Run::Step(State::Eval(
891903 next,
892904 env.clone(),
893905 Cont::LetRest {
894− exprs,
906+ bindings,
907+ at,
895908 cell,
896909 body,
897910 env,
@@ -973,19 +986,16 @@ impl Interp {
973986 v.kind_name()
974987 )));
975988 }
976− let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
977− let exprs: Vec<Rc<Expr>> = fields.iter().map(|(_, e)| e.clone()).collect();
978− if exprs.is_empty() {
989+ if fields.is_empty() {
979990 return Run::Step(State::Ret(v, (*k).clone()));
980991 }
981992 Run::Step(State::Eval(
982− exprs[0].clone(),
993+ fields[0].1.clone(),
983994 env.clone(),
984995 Cont::UpdateFields {
985996 base: v,
986− names,
987− exprs,
988− acc: Vec::new(),
997+ acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
998+ fields,
989999 env,
9901000 cont: k,
9911001 },
@@ -993,20 +1003,23 @@ impl Interp {
9931003 }
9941004 Cont::UpdateFields {
9951005 base,
996− names,
997− exprs,
998− mut acc,
1006+ fields,
1007+ acc,
9991008 env,
10001009 cont,
10011010 } => {
1002− let idx = acc.len();
1003− acc.push((names[idx].clone(), v));
1004− if acc.len() == names.len() {
1011+ let filled = {
1012+ let mut values = acc.borrow_mut();
1013+ let idx = values.len();
1014+ values.push((fields[idx].0.clone(), v));
1015+ values.len()
1016+ };
1017+ if filled == fields.len() {
10051018 let mut m = match &base {
10061019 Value::Record(m) => m.as_ref().clone(),
10071020 _ => unreachable!(),
10081021 };
1009− for (n, val) in acc {
1022+ for (n, val) in acc.take() {
10101023 if !m.contains_key(&n) {
10111024 return Run::Crash(Crash::new(format!(
10121025 "cannot update: record has no field `{}`",
@@ -1017,14 +1030,13 @@ impl Interp {
10171030 }
10181031 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
10191032 } else {
1020− let next = exprs[acc.len()].clone();
1033+ let next = fields[filled].1.clone();
10211034 Run::Step(State::Eval(
10221035 next,
10231036 env.clone(),
10241037 Cont::UpdateFields {
10251038 base,
1026− names,
1027− exprs,
1039+ fields,
10281040 acc,
10291041 env,
10301042 cont,
@@ -1033,25 +1045,27 @@ impl Interp {
10331045 }
10341046 }
10351047 Cont::RecordFields {
1036− names,
1037− exprs,
1038− mut acc,
1048+ fields,
1049+ acc,
10391050 env,
10401051 cont,
10411052 } => {
1042− let idx = acc.len();
1043− acc.push((names[idx].clone(), v));
1044− if acc.len() == names.len() {
1045− let m: crate::value::RecordMap = acc.into_iter().collect();
1053+ let filled = {
1054+ let mut values = acc.borrow_mut();
1055+ let idx = values.len();
1056+ values.push((fields[idx].0.clone(), v));
1057+ values.len()
1058+ };
1059+ if filled == fields.len() {
1060+ let m: crate::value::RecordMap = acc.take().into_iter().collect();
10461061 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
10471062 } else {
1048− let next = exprs[acc.len()].clone();
1063+ let next = fields[filled].1.clone();
10491064 Run::Step(State::Eval(
10501065 next,
10511066 env.clone(),
10521067 Cont::RecordFields {
1053− names,
1054− exprs,
1068+ fields,
10551069 acc,
10561070 env,
10571071 cont,
@@ -1061,15 +1075,19 @@ impl Interp {
10611075 }
10621076 Cont::ListElems {
10631077 exprs,
1064− mut acc,
1078+ acc,
10651079 env,
10661080 cont,
10671081 } => {
1068− acc.push(v);
1069− if acc.len() == exprs.len() {
1070− Run::Step(State::Ret(Value::list(acc), (*cont).clone()))
1082+ let filled = {
1083+ let mut values = acc.borrow_mut();
1084+ values.push(v);
1085+ values.len()
1086+ };
1087+ if filled == exprs.len() {
1088+ Run::Step(State::Ret(Value::list(acc.take()), (*cont).clone()))
10711089 } else {
1072− let next = exprs[acc.len()].clone();
1090+ let next = exprs[filled].clone();
10731091 Run::Step(State::Eval(
10741092 next,
10751093 env.clone(),
src/jj.rs+301−39
@@ -6,7 +6,7 @@
66use crate::config::Config;
77use crate::domain::{Backend, MetaInfo, ROOT_ID};
88use crate::eval::Interp;
9−use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, LazyBlob, ThunkVal, Value};
9+use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, LazyBlob, ListVal, ThunkVal, Value};
1010use futures::{StreamExt, TryStreamExt};
1111use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
1212use jj_lib::commit::Commit;
@@ -31,12 +31,12 @@ use jj_lib::store::Store;
3131use jj_lib::transaction::UnpublishedOperation;
3232use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
3333use jj_lib::workspace::Workspace;
34−use std::cell::RefCell;
34+use std::cell::{Cell, RefCell};
3535use std::collections::{BTreeMap, BTreeSet, HashMap};
3636use std::future::Future;
3737use std::os::unix::io::AsRawFd;
3838use std::path::PathBuf;
39−use std::rc::Rc;
39+use std::rc::{Rc, Weak};
4040use std::sync::{Arc, Mutex, OnceLock};
4141
4242fn block_on<F: Future>(f: F) -> F::Output {
@@ -1416,6 +1416,7 @@ fn with_tree(commit: &Commit, tree: MergedTree) -> Commit {
14161416#[derive(Clone)]
14171417pub struct JjBackend {
14181418 inner: Arc<JjInner>,
1419+ replay_cache: Rc<ReplayCache>,
14191420}
14201421
14211422impl JjBackend {
@@ -1448,6 +1449,7 @@ impl JjBackend {
14481449 let repo = block_on(workspace.repo_loader().load_at_head())
14491450 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
14501451 Ok(JjBackend {
1452+ replay_cache: Rc::new(ReplayCache::default()),
14511453 inner: Arc::new(JjInner {
14521454 workspace_root: root,
14531455 workspace: Mutex::new(workspace),
@@ -1502,7 +1504,7 @@ impl JjBackend {
15021504 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
15031505 *self.inner.repo.lock().unwrap() = head_repo;
15041506 let loaded_visible = Arc::new(block_on(read_visible(&self.current_repo()))?);
1505− let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id)?;
1507+ let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id, &self.replay_cache)?;
15061508
15071509 let current = if snapshot {
15081510 // §7.4: snapshot the working directory into the wc commit; the
@@ -1519,7 +1521,7 @@ impl JjBackend {
15191521 Some(pending) => {
15201522 *self.inner.pending.lock().unwrap() = Some(pending);
15211523 let vis = block_on(read_visible(&post))?;
1522− let cur = build_repo_value(&vis, &vis.wc_change_id)?;
1524+ let cur = build_repo_value(&vis, &vis.wc_change_id, &self.replay_cache)?;
15231525 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
15241526 cur
15251527 }
@@ -2729,6 +2731,7 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
27292731 }
27302732 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
27312733 let backend = JjBackend {
2734+ replay_cache: Rc::new(ReplayCache::default()),
27322735 inner: Arc::new(JjInner {
27332736 workspace_root: cwd.clone(),
27342737 workspace: Mutex::new(workspace),
@@ -2973,6 +2976,7 @@ fn clone_into(
29732976 ))
29742977 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
29752978 let backend = JjBackend {
2979+ replay_cache: Rc::new(ReplayCache::default()),
29762980 inner: Arc::new(JjInner {
29772981 workspace_root: dir_path.to_path_buf(),
29782982 workspace: Mutex::new(workspace),
@@ -3749,8 +3753,16 @@ impl Backend for JjBackend {
37493753 }
37503754
37513755 fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
3756+ self.replay_snapshot(
3757+ &Value::list(onto.to_vec()),
3758+ &Value::list(from.to_vec()),
3759+ &Value::list(to.to_vec()),
3760+ )?.as_list().map(<[Value]>::to_vec)
3761+ }
3762+
3763+ fn replay_snapshot(&self, onto: &Value, from: &Value, to: &Value) -> Result<Value, Crash> {
37523764 let store = self.current_repo().store().clone();
3753− block_on(jj_replay(&store, onto, from, to))
3765+ block_on(jj_replay(&store, &self.replay_cache, onto, from, to))
37543766 }
37553767}
37563768
@@ -3758,15 +3770,119 @@ impl Backend for JjBackend {
37583770// jj three-way tree merge for `replay` (§7.3)
37593771// ----------------------------------------------------------------------
37603772
3773+/// Replay sees the same lists repeatedly: a parent's old and new files
3774+/// become each child's bases. Retain their validated trees by list identity,
3775+/// including its offset, without keeping discarded snapshots alive.
3776+#[derive(Default)]
3777+struct ReplayCache {
3778+ trees: RefCell<HashMap<(*const Vec<Value>, usize), ReplayTree>>,
3779+ empty_subtrees: RefCell<HashMap<TreeId, bool>>,
3780+ next_sweep: Cell<usize>,
3781+ blobs: BlobCache,
3782+ entries: EntryCache,
3783+}
3784+
3785+struct ReplayTree {
3786+ storage: Weak<Vec<Value>>,
3787+ tree: MergedTree,
3788+ /// A list read from a resolved tree, already in canonical path order.
3789+ canonical: bool,
3790+}
3791+
3792+impl ReplayCache {
3793+ fn remember(&self, list: &ListVal, tree: MergedTree, canonical: bool) {
3794+ let mut trees = self.trees.borrow_mut();
3795+ trees.insert(list.identity(), ReplayTree {
3796+ storage: list.weak_storage(),
3797+ tree,
3798+ canonical,
3799+ });
3800+ if trees.len() >= self.next_sweep.get().max(256) {
3801+ trees.retain(|_, t| t.storage.strong_count() != 0);
3802+ self.next_sweep.set((trees.len() * 2).max(256));
3803+ }
3804+ }
3805+
3806+ async fn tree_of(&self, store: &Arc<Store>, files: &Value) -> Result<(MergedTree, bool), Crash> {
3807+ let Value::List(list) = files else {
3808+ files.as_list()?;
3809+ unreachable!();
3810+ };
3811+ if let Some(cached) = self.trees.borrow().get(&list.identity()) {
3812+ // The weak reference keeps its allocation reserved, so another
3813+ // list cannot reuse this address while the key is cached.
3814+ if cached.storage.strong_count() != 0 {
3815+ return Ok((cached.tree.clone(), cached.canonical));
3816+ }
3817+ }
3818+ // Validate every new list before caching it, even when from == to:
3819+ // an unchanged change must still reject malformed snapshots.
3820+ let tree = build_tree(store, files).await?;
3821+ self.remember(list, tree.clone(), false);
3822+ Ok((tree, false))
3823+ }
3824+
3825+ async fn files_of(&self, store: &Arc<Store>, tree: &MergedTree) -> Result<Value, Crash> {
3826+ let (files, reusable) = tree_to_files_inner(store, tree, &self.blobs, &self.entries).await?;
3827+ let list = ListVal::new(files);
3828+ if reusable && !self.has_empty_subtrees(tree).await? {
3829+ self.remember(&list, tree.clone(), true);
3830+ }
3831+ Ok(Value::List(list))
3832+ }
3833+
3834+ /// Flattening a Git tree omits empty directories. Reusing such a tree
3835+ /// would let a replay conflict with a directory absent from its input
3836+ /// snapshot. Inspect directories once per tree, without rebuilding files.
3837+ async fn has_empty_subtrees(&self, tree: &MergedTree) -> Result<bool, Crash> {
3838+ let Some(root) = tree.tree_ids().as_resolved() else { return Ok(true) };
3839+ if let Some(found) = self.empty_subtrees.borrow().get(root) {
3840+ return Ok(*found);
3841+ }
3842+ let store = tree.store();
3843+ let mut pending = vec![(RepoPathBuf::root(), root.clone())];
3844+ let mut visited = BTreeSet::new();
3845+ while let Some((dir, id)) = pending.pop() {
3846+ if !visited.insert(id.clone()) {
3847+ continue;
3848+ }
3849+ let known = self.empty_subtrees.borrow().get(&id).copied();
3850+ if let Some(found) = known {
3851+ if found {
3852+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
3853+ return Ok(true);
3854+ }
3855+ continue;
3856+ }
3857+ let loaded = store.get_tree(dir.clone(), &id).await
3858+ .map_err(|e| Crash::new(format!("cannot read a tree: {}", e)))?;
3859+ for entry in loaded.entries_non_recursive() {
3860+ if let TreeValue::Tree(child) = entry.value() {
3861+ if child == store.empty_tree_id() {
3862+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
3863+ return Ok(true);
3864+ }
3865+ pending.push((dir.join(entry.name()), child.clone()));
3866+ }
3867+ }
3868+ }
3869+ // Every subtree visited was nonempty all the way down. Sharing this
3870+ // answer also avoids rescanning unchanged directories of later trees.
3871+ self.empty_subtrees.borrow_mut().extend(visited.into_iter().map(|id| (id, false)));
3872+ Ok(false)
3873+ }
3874+}
3875+
37613876async fn jj_replay(
37623877 store: &Arc<Store>,
3763− onto: &[Value],
3764− from: &[Value],
3765− to: &[Value],
3766−) -> Result<Vec<Value>, Crash> {
3767− let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
3768− let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
3769− let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
3878+ cache: &ReplayCache,
3879+ onto: &Value,
3880+ from: &Value,
3881+ to: &Value,
3882+) -> Result<Value, Crash> {
3883+ let (onto_tree, onto_canonical) = cache.tree_of(store, onto).await?;
3884+ let (from_tree, from_canonical) = cache.tree_of(store, from).await?;
3885+ let (to_tree, to_canonical) = cache.tree_of(store, to).await?;
37703886 // any of the three may itself be conflicted (a snapshot with unresolved
37713887 // blobs, §7.3), so merge the trees the way jj's rebase does:
37723888 // MergedTree::merge flattens conflicted inputs into one merge, simplifies
@@ -3780,8 +3896,20 @@ async fn jj_replay(
37803896 .await
37813897 .map_err(|e| Crash::new(format!("replay: {}", e)))?;
37823898 refuse_file_directory_clash(&merged, &onto_tree, &from_tree, &to_tree).await?;
3899+ // Reusing a resolved tree's list preserves replay's sorted output. A
3900+ // constructed list may have another order, and conflicts may simplify
3901+ // on reading, so neither can take this shortcut.
3902+ for (files, tree, canonical) in [
3903+ (onto, &onto_tree, onto_canonical),
3904+ (to, &to_tree, to_canonical),
3905+ (from, &from_tree, from_canonical),
3906+ ] {
3907+ if canonical && merged.tree_ids() == tree.tree_ids() {
3908+ return Ok(files.clone());
3909+ }
3910+ }
37833911 // conflicts stay as unresolved blobs (§7.3)
3784− tree_to_files(store, &merged, &BlobCache::default(), &EntryCache::default()).await
3912+ cache.files_of(store, &merged).await
37853913}
37863914
37873915/// Crash if the merge left a conflict with a file on one side and, on
@@ -4077,34 +4205,49 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
40774205// jj trees -> values
40784206// ----------------------------------------------------------------------
40794207
4080−/// blob contents keyed by file id, shared across the whole repo load: most
4081−/// files are unchanged between commits, so inflating each blob once (instead
4082−/// of once per commit that references it) keeps loading linear in the
4083−/// repository's unique data rather than commits × files
4208+/// Blob contents keyed by file id, shared while snapshots hold their bytes.
4209+/// Weak entries let discarded replay results release their conflict sides.
40844210#[derive(Clone, Default)]
4085−struct BlobCache(Rc<RefCell<HashMap<FileId, Rc<Vec<u8>>>>>);
4211+struct BlobCache {
4212+ bytes: Rc<RefCell<HashMap<FileId, Weak<Vec<u8>>>>>,
4213+ next_sweep: Rc<Cell<usize>>,
4214+}
40864215
40874216impl BlobCache {
40884217 fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
4089− self.0.borrow().get(id).cloned()
4218+ self.bytes.borrow().get(id).and_then(Weak::upgrade)
40904219 }
40914220 fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
4092− self.0.borrow_mut().insert(id, bytes);
4221+ let mut cached = self.bytes.borrow_mut();
4222+ cached.insert(id, Rc::downgrade(&bytes));
4223+ if cached.len() >= self.next_sweep.get().max(256) {
4224+ cached.retain(|_, bytes| bytes.strong_count() != 0);
4225+ self.next_sweep.set((cached.len() * 2).max(256));
4226+ }
40934227 }
40944228}
40954229
4096−/// whole file *entries* ({path, content}) keyed by (path, blob identity),
4097−/// shared across the whole repo load: an unchanged file is the same Value in
4098−/// every commit, so it is built once and cloned (an Rc bump) thereafter
4230+/// Whole file entries keyed by (path, blob identity), shared by loaded
4231+/// snapshots and replays. Weak references let transient replay results go
4232+/// away while unchanged entries still held by a snapshot are reused.
40994233#[derive(Clone, Default)]
4100−struct EntryCache(Rc<RefCell<HashMap<String, Value>>>);
4234+struct EntryCache {
4235+ entries: Rc<RefCell<HashMap<String, Weak<crate::value::RecordMap>>>>,
4236+ next_sweep: Rc<Cell<usize>>,
4237+}
41014238
41024239impl EntryCache {
41034240 fn get(&self, key: &str) -> Option<Value> {
4104− self.0.borrow().get(key).cloned()
4241+ self.entries.borrow().get(key).and_then(Weak::upgrade).map(Value::Record)
41054242 }
41064243 fn insert(&self, key: String, entry: Value) {
4107− self.0.borrow_mut().insert(key, entry);
4244+ let Value::Record(record) = entry else { return };
4245+ let mut entries = self.entries.borrow_mut();
4246+ entries.insert(key, Rc::downgrade(&record));
4247+ if entries.len() >= self.next_sweep.get().max(256) {
4248+ entries.retain(|_, entry| entry.strong_count() != 0);
4249+ self.next_sweep.set((entries.len() * 2).max(256));
4250+ }
41084251 }
41094252}
41104253
@@ -4284,9 +4427,27 @@ async fn tree_to_files(
42844427 cache: &BlobCache,
42854428 entries: &EntryCache,
42864429) -> Result<Vec<Value>, Crash> {
4430+ tree_to_files_inner(store, tree, cache, entries).await.map(|(files, _)| files)
4431+}
4432+
4433+/// Whether these files can reuse the original tree in replay. Reading a
4434+/// conflict simplifies it, and an unsupported git submodule reads as an
4435+/// empty file, so their original trees cannot represent the resulting list.
4436+async fn tree_to_files_inner(
4437+ store: &Arc<Store>,
4438+ tree: &MergedTree,
4439+ cache: &BlobCache,
4440+ entries: &EntryCache,
4441+) -> Result<(Vec<Value>, bool), Crash> {
42874442 let mut out = Vec::new();
4443+ let mut reusable = tree.tree_ids().is_resolved();
42884444 for (path, value) in tree.entries() {
42894445 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
4446+ reusable &= match value.as_resolved() {
4447+ Some(Some(TreeValue::File { copy_id, .. })) => *copy_id == CopyId::placeholder(),
4448+ Some(Some(TreeValue::Symlink(_))) => true,
4449+ _ => false,
4450+ };
42904451 // cache key: the entry is fully determined by its path and the
42914452 // identity of its (resolved) content; conflicts are not cached
42924453 let key = match value.as_resolved() {
@@ -4317,7 +4478,7 @@ async fn tree_to_files(
43174478 };
43184479 out.push(entry);
43194480 }
4320− Ok(out)
4481+ Ok((out, reusable))
43214482}
43224483
43234484// ----------------------------------------------------------------------
@@ -4360,15 +4521,13 @@ fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
43604521// building the Repo value from the visible map (§7.2)
43614522// ----------------------------------------------------------------------
43624523
4363−fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, OpenError> {
4524+fn build_repo_value(vis: &VisibleRepo, focus_change: &str, replay_cache: &Rc<ReplayCache>) -> Result<Value, OpenError> {
43644525 let root_rec = vis
43654526 .commits
43664527 .get(ROOT_ID)
43674528 .ok_or_else(|| (2, "no root commit".to_string()))?;
43684529 let store = root_rec.commit.store().clone();
4369− let cache = BlobCache::default();
4370− let entries = EntryCache::default();
4371− let mut subtree = build_subtree(vis, root_rec, &store, &cache, &entries)?;
4530+ let mut subtree = build_subtree(vis, root_rec, &store, replay_cache)?;
43724531 sort_subtree(&mut subtree, vis);
43734532 let top = Value::record(&[
43744533 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -4385,8 +4544,7 @@ fn build_subtree(
43854544 vis: &VisibleRepo,
43864545 rec: &StoredCommit,
43874546 store: &Arc<Store>,
4388− cache: &BlobCache,
4389− entries: &EntryCache,
4547+ replay_cache: &Rc<ReplayCache>,
43904548) -> Result<Value, OpenError> {
43914549 // the files list is lazy (§7.2): most commits are never inspected, so
43924550 // their file entries are materialized only on first `field("files")`.
@@ -4397,11 +4555,9 @@ fn build_subtree(
43974555 let store = store.clone();
43984556 let tree = rec.commit.tree();
43994557 let name = Some(tree_name(&tree));
4400− let cache = cache.clone();
4401− let entries = entries.clone();
4558+ let replay_cache = replay_cache.clone();
44024559 Value::Thunk(Rc::new(crate::value::ThunkVal::stored(rec.change_id.clone(), name, move || {
4403− let files = block_on(tree_to_files(&store, &tree, &cache, &entries))?;
4404− Ok(Value::list(files))
4560+ block_on(replay_cache.files_of(&store, &tree))
44054561 })))
44064562 };
44074563 let labels: Vec<Value> = vis
@@ -4422,7 +4578,7 @@ fn build_subtree(
44224578 if let Some(kids) = vis.children.get(&rec.change_id) {
44234579 for kid in kids {
44244580 if let Some(krec) = vis.commits.get(kid) {
4425− children.push(build_subtree(vis, krec, store, cache, entries)?);
4581+ children.push(build_subtree(vis, krec, store, replay_cache)?);
44264582 }
44274583 }
44284584 }
@@ -4464,6 +4620,112 @@ mod tests {
44644620 use jj_lib::backend::SymlinkId;
44654621 use jj_lib::conflict_labels::ConflictLabels;
44664622
4623+ fn replay_store() -> (tempfile::TempDir, Arc<Store>) {
4624+ let dir = tempfile::tempdir().unwrap();
4625+ let (_, repo) = block_on(Workspace::init_colocated_git(
4626+ &user_settings(None), dir.path(), gix::hash::Kind::Sha1,
4627+ )).unwrap();
4628+ (dir, repo.store().clone())
4629+ }
4630+
4631+ fn file(path: &str, content: &str) -> Value {
4632+ Value::record(&[
4633+ ("path", Value::list(vec![Value::text(path)])),
4634+ ("content", BlobVal::text_blob(content)),
4635+ ])
4636+ }
4637+
4638+ fn loaded_files(store: &Arc<Store>, cache: &ReplayCache, files: Vec<Value>) -> Value {
4639+ let tree = block_on(build_tree(store, &Value::list(files))).unwrap();
4640+ block_on(cache.files_of(store, &tree)).unwrap()
4641+ }
4642+
4643+ #[test]
4644+ fn replay_reuses_loaded_snapshots_and_unchanged_entries() {
4645+ let (_dir, store) = replay_store();
4646+ let cache = ReplayCache::default();
4647+ let base = loaded_files(&store, &cache, vec![file("a", "base a"), file("b", "base b")]);
4648+ let same = block_on(jj_replay(&store, &cache, &base, &base, &base)).unwrap();
4649+ let (Value::List(before), Value::List(after)) = (&base, &same) else { panic!() };
4650+ assert_eq!(before.identity(), after.identity(), "no-op replay must retain the snapshot");
4651+
4652+ let onto = loaded_files(&store, &cache, vec![file("a", "onto"), file("b", "base b")]);
4653+ let to = loaded_files(&store, &cache, vec![file("a", "base a"), file("b", "to")]);
4654+ let merged = block_on(jj_replay(&store, &cache, &onto, &base, &to)).unwrap();
4655+ let expected = Value::list(vec![file("a", "onto"), file("b", "to")]);
4656+ assert!(crate::value::value_eq(&merged, &expected).unwrap());
4657+ for (result, input) in [
4658+ (&merged.as_list().unwrap()[0], &onto.as_list().unwrap()[0]),
4659+ (&merged.as_list().unwrap()[1], &to.as_list().unwrap()[1]),
4660+ ] {
4661+ let (Value::Record(result), Value::Record(input)) = (result, input) else { panic!() };
4662+ assert!(Rc::ptr_eq(result, input), "unchanged file entries must be shared");
4663+ }
4664+ }
4665+
4666+ #[test]
4667+ fn replay_cache_preserves_validation_ordering_and_list_offsets() {
4668+ let (_dir, store) = replay_store();
4669+ let cache = ReplayCache::default();
4670+ let unsorted = Value::list(vec![file("b", "b"), file("a", "a")]);
4671+ let empty = Value::list(vec![]);
4672+ let sorted = block_on(jj_replay(&store, &cache, &unsorted, &empty, &empty)).unwrap();
4673+ assert!(crate::value::value_eq(&sorted, &Value::list(vec![file("a", "a"), file("b", "b")])).unwrap());
4674+
4675+ let Value::List(list) = &sorted else { panic!() };
4676+ let tail = Value::List(list.skip(1));
4677+ let replayed_tail = block_on(jj_replay(&store, &cache, &tail, &empty, &empty)).unwrap();
4678+ assert!(crate::value::value_eq(&tail, &replayed_tail).unwrap());
4679+ assert_eq!(replayed_tail.as_list().unwrap().len(), 1);
4680+
4681+ let malformed = Value::list(vec![file("a", "one"), file("a", "two")]);
4682+ let err = block_on(jj_replay(&store, &cache, &sorted, &malformed, &malformed)).unwrap_err();
4683+ assert!(err.msg.contains("duplicate paths"), "{}", err.msg);
4684+ }
4685+
4686+ #[test]
4687+ fn replay_ignores_empty_directories_not_represented_by_snapshots() {
4688+ let (_dir, store) = replay_store();
4689+ let cache = ReplayCache::default();
4690+ let entry = |id| jj_lib::backend::Tree::from_sorted_entries(vec![
4691+ (RepoPathComponentBuf::new("d".to_string()).unwrap(), TreeValue::Tree(id)),
4692+ ]);
4693+ let direct = block_on(store.write_tree(RepoPath::root(), entry(store.empty_tree_id().clone()))).unwrap();
4694+ let nested = block_on(store.write_tree(RepoPath::root(), entry(direct.id().clone()))).unwrap();
4695+ let empty = Value::list(vec![]);
4696+ let to = Value::list(vec![file("d", "file")]);
4697+ for tree in [direct, nested] {
4698+ let tree = MergedTree::resolved(store.clone(), tree.id().clone());
4699+ let onto = block_on(cache.files_of(&store, &tree)).unwrap();
4700+ assert!(onto.as_list().unwrap().is_empty());
4701+ let result = block_on(jj_replay(&store, &cache, &onto, &empty, &to)).unwrap();
4702+ assert!(crate::value::value_eq(&result, &to).unwrap());
4703+ }
4704+ }
4705+
4706+ #[test]
4707+ fn replay_cache_does_not_keep_discarded_snapshots_alive() {
4708+ let (_dir, store) = replay_store();
4709+ let cache = ReplayCache::default();
4710+ let files = loaded_files(&store, &cache, vec![file("a", "a")]);
4711+ let Value::List(list) = &files else { panic!() };
4712+ let storage = list.weak_storage();
4713+ let Value::Record(entry) = &files.as_list().unwrap()[0] else { panic!() };
4714+ let entry = Rc::downgrade(entry);
4715+ drop(files);
4716+ assert!(storage.upgrade().is_none());
4717+ assert!(entry.upgrade().is_none());
4718+
4719+ let bytes = Rc::new(vec![1, 2, 3]);
4720+ let weak_bytes = Rc::downgrade(&bytes);
4721+ let id = FileId::new(vec![1; 20]);
4722+ cache.blobs.insert(id.clone(), bytes.clone());
4723+ assert!(Rc::ptr_eq(&cache.blobs.get(&id).unwrap(), &bytes));
4724+ drop(bytes);
4725+ assert!(weak_bytes.upgrade().is_none());
4726+ assert!(cache.blobs.get(&id).is_none());
4727+ }
4728+
44674729 #[test]
44684730 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
44694731 // what a checkout writes for a symlink/file conflict of five terms,
src/lib.rs+1
@@ -16,6 +16,7 @@ pub mod config;
1616pub mod domain;
1717pub mod eval;
1818pub mod lex;
19+mod lookup;
1920pub mod parse;
2021pub mod render;
2122pub mod repo;
src/lookup.rsnew file+78
@@ -0,0 +1,78 @@
1+//! Indexed snapshot lookups used by the reference configuration.
2+
3+use crate::value::{value_eq, BlobVal, Crash, ListVal, Value};
4+use std::cell::RefCell;
5+use std::collections::{HashMap, VecDeque};
6+use std::rc::Weak;
7+
8+struct SnapshotIndex {
9+ identity: (*const Vec<Value>, usize),
10+ storage: Weak<Vec<Value>>,
11+ // None means a non-path was encountered: use structural equality, as
12+ // the language's entryAt does, including errors in later entries.
13+ paths: Option<HashMap<Vec<String>, usize>>,
14+}
15+
16+thread_local! {
17+ // `diffs` alternates the before/after snapshots. Weak keys and integer
18+ // positions keep neither snapshot nor its blobs alive after the caller.
19+ static SNAPSHOTS: RefCell<VecDeque<SnapshotIndex>> = const { RefCell::new(VecDeque::new()) };
20+}
21+
22+fn path_key(path: &Value) -> Option<Vec<String>> {
23+ path.as_list().ok()?.iter()
24+ .map(|v| v.as_text().ok().map(str::to_owned))
25+ .collect()
26+}
27+
28+fn indexed_entry(path: &[String], entries: &ListVal) -> Option<Option<usize>> {
29+ SNAPSHOTS.with(|cache| {
30+ let mut cache = cache.borrow_mut();
31+ let identity = entries.identity();
32+ if let Some(pos) = cache.iter().position(|c| {
33+ c.identity == identity && c.storage.strong_count() > 0
34+ }) {
35+ let entry = cache.remove(pos).unwrap();
36+ cache.push_front(entry);
37+ } else {
38+ let paths = (|| {
39+ let mut paths = HashMap::new();
40+ for (i, entry) in entries.iter().enumerate() {
41+ let path = path_key(&entry.field("path").ok()?)?;
42+ paths.entry(path).or_insert(i);
43+ }
44+ Some(paths)
45+ })();
46+ cache.push_front(SnapshotIndex {
47+ identity,
48+ storage: entries.weak_storage(),
49+ paths,
50+ });
51+ cache.truncate(2);
52+ }
53+ cache.front().unwrap().paths.as_ref()
54+ .map(|paths| paths.get(path).copied())
55+ })
56+}
57+
58+/// `(head (filter (\e -> e.path == path) files)).content or blob ""`.
59+/// Index ordinary paths; preserve the full filter's errors and first-match
60+/// behavior for arbitrary values the shallow Snapshot contract also admits.
61+pub(crate) fn content_at(path: &Value, files: &Value) -> Result<Value, Crash> {
62+ let lookup = || -> Result<Value, Crash> {
63+ if let (Some(key), Value::List(entries)) = (path_key(path), files) {
64+ if let Some(found) = indexed_entry(&key, entries) {
65+ return found.ok_or_else(|| Crash::new("head: empty list"))
66+ .and_then(|i| entries[i].field("content"));
67+ }
68+ }
69+ let mut first = None;
70+ for entry in files.as_list()? {
71+ if value_eq(&entry.field("path")?, path)? && first.is_none() {
72+ first = Some(entry);
73+ }
74+ }
75+ first.ok_or_else(|| Crash::new("head: empty list"))?.field("content")
76+ };
77+ Ok(lookup().unwrap_or_else(|_| BlobVal::text_blob("")))
78+}
src/render.rs+110−92
@@ -3,7 +3,8 @@
33use crate::domain::ROOT_ID;
44use crate::eval::Interp;
55use crate::value::{Crash, ListVal, RecordMap, ShapeKind, Value};
6−use std::collections::{BTreeMap, BTreeSet};
6+use std::cmp::Reverse;
7+use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
78use std::ops::Bound;
89use std::rc::Rc;
910use unicode_width::UnicodeWidthChar;
@@ -1836,25 +1837,25 @@ fn tree_render(
18361837 // Step 2 — row order (topological, oldest first)
18371838 let mut flat: Vec<&Display> = Vec::new();
18381839 flatten_display(&droot, &mut flat);
1839− let preorder: BTreeMap<usize, usize> =
1840− flat.iter().enumerate().map(|(i, n)| (n.uid(), i)).collect();
1841− let mut ready: Vec<&Display> = vec![&droot];
1840+ // Display uids are dense. Keep the preorder position as the heap's
1841+ // second key, so a newly ready descendant wins a time tie with a later
1842+ // sibling just as it does in the specification's ready set.
1843+ let mut preorder = vec![0; flat.len()];
1844+ for (i, n) in flat.iter().enumerate() {
1845+ preorder[n.uid()] = i;
1846+ }
1847+ let mut ready = BinaryHeap::from([Reverse((droot.time(), 0usize))]);
18421848 let mut rows: Vec<&Display> = Vec::new();
1843− while !ready.is_empty() {
1844− let best = ready
1845− .iter()
1846− .enumerate()
1847− .min_by_key(|(_, n)| (n.time(), preorder[&n.uid()]))
1848− .map(|(i, _)| i)
1849− .unwrap();
1850− let n = ready.remove(best);
1849+ while let Some(Reverse((_, i))) = ready.pop() {
1850+ let n = flat[i];
18511851 rows.push(n);
18521852 for c in n.children() {
1853− ready.push(c);
1853+ ready.push(Reverse((c.time(), preorder[c.uid()])));
18541854 }
18551855 }
18561856 // Step 3 — lanes
1857− let placements = assign_lanes(&rows, &trunk, lanes_n);
1857+ let index = RowIndex::new(&rows);
1858+ let placements = assign_lanes(&rows, &index, &trunk, lanes_n);
18581859 // id column: shortest unique prefix among the display tree's commits, min 4
18591860 // (sorted once, so each row costs a lookup rather than a scan of them all)
18601861 let mut ids: Vec<String> = rows
@@ -1866,7 +1867,7 @@ fn tree_render(
18661867 crate::eval::unique_prefix_in(&ids, id).len()
18671868 };
18681869 // Step 4 — draw
1869− draw_rows(&rows, &placements, opts, pal, lanes_n, with_focus, &prefix_len, term_w)
1870+ draw_rows(&rows, &index, &placements, opts, pal, lanes_n, with_focus, &prefix_len, term_w)
18701871}
18711872
18721873fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
@@ -2142,23 +2143,65 @@ fn mark_subtree(n: &Display, set: &mut BTreeSet<usize>) {
21422143 }
21432144}
21442145
2145−fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) -> Vec<Placement> {
2146+/// Relations in display-row order, indexed once for lane assignment and
2147+/// size bars. Finding a parent or the focus afresh on every row made even a
2148+/// single line of history take quadratic time.
2149+struct RowIndex {
2150+ row_of: Vec<usize>,
2151+ parent: Vec<Option<usize>>,
2152+ last_child: Vec<Option<usize>>,
2153+ focus_adjacent: Vec<bool>,
2154+}
2155+
2156+impl RowIndex {
2157+ fn new(rows: &[&Display]) -> Self {
2158+ let mut index = RowIndex {
2159+ row_of: vec![0; rows.len()],
2160+ parent: vec![None; rows.len()],
2161+ last_child: vec![None; rows.len()],
2162+ focus_adjacent: vec![false; rows.len()],
2163+ };
2164+ for (i, n) in rows.iter().enumerate() {
2165+ index.row_of[n.uid()] = i;
2166+ }
2167+ for (i, n) in rows.iter().enumerate() {
2168+ for c in n.children() {
2169+ let child = index.row_of[c.uid()];
2170+ index.parent[child] = Some(i);
2171+ index.last_child[i] = Some(index.last_child[i].map_or(child, |last| last.max(child)));
2172+ }
2173+ }
2174+ if let Some(focus) = rows.iter().position(|n| n.commit().is_some_and(|c| c.is_focus)) {
2175+ if let Some(parent) = index.parent[focus] {
2176+ index.focus_adjacent[parent] = true;
2177+ }
2178+ for c in rows[focus].children() {
2179+ index.focus_adjacent[index.row_of[c.uid()]] = true;
2180+ }
2181+ }
2182+ index
2183+ }
2184+}
2185+
2186+fn assign_lanes(
2187+ rows: &[&Display],
2188+ index: &RowIndex,
2189+ trunk: &BTreeSet<String>,
2190+ lanes_n: usize,
2191+) -> Vec<Placement> {
21462192 let mut rails: Vec<Option<Rail>> = vec![None; lanes_n];
21472193 let mut out: Vec<Placement> = Vec::new();
21482194 let mut flattened: BTreeSet<usize> = BTreeSet::new();
21492195 // each node's row: "last child" and "after t" are in row order, which is
21502196 // not sibling order once an edit appends a child (`new`, `rebase`)
2151− let row_of: BTreeMap<usize, usize> =
2152− rows.iter().enumerate().map(|(i, n)| (n.uid(), i)).collect();
2197+ let row_of = &index.row_of;
21532198 for (idx, n) in rows.iter().enumerate() {
21542199 let in_trunk = n.in_trunk(trunk);
2155− let parent = rows[..idx]
2156− .iter()
2157− .copied()
2158− .find(|p| p.children().iter().any(|c| c.uid() == n.uid()));
2159− let parent_lane = parent.and_then(|p| out[rows[..idx].iter().position(|x| x.uid() == p.uid()).unwrap()].lane);
2160− let is_last_child = parent
2161− .map(|p| p.children().iter().all(|c| row_of[&c.uid()] <= idx))
2200+ let parent_idx = index.parent[idx];
2201+ let parent = parent_idx.map(|i| rows[i]);
2202+ let parent_lane = parent_idx.and_then(|i| out[i].lane);
2203+ let is_last_child = parent_idx
2204+ .map(|i| index.last_child[i] == Some(idx))
21622205 .unwrap_or(false);
21632206 let was_flat = flattened.contains(&n.uid());
21642207 let mut lane: Option<usize> = None;
@@ -2225,9 +2268,9 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
22252268 if let Some(t) = cs.iter().find(|c| c.in_trunk(trunk)) {
22262269 let mut after: Vec<&Display> = cs
22272270 .iter()
2228− .filter(|c| row_of[&c.uid()] > row_of[&t.uid()])
2271+ .filter(|c| row_of[c.uid()] > row_of[t.uid()])
22292272 .collect();
2230− after.sort_by_key(|c| row_of[&c.uid()]);
2273+ after.sort_by_key(|c| row_of[c.uid()]);
22312274 for c in after {
22322275 if flattened.contains(&c.uid()) {
22332276 continue;
@@ -2273,7 +2316,7 @@ struct RowText {
22732316
22742317fn build_row_text(
22752318 n: &Display,
2276− rows: &[&Display],
2319+ focus_adjacent: bool,
22772320 opts: &TreeOptions,
22782321 pal: &Palette,
22792322 with_focus: bool,
@@ -2311,23 +2354,8 @@ fn build_row_text(
23112354 },
23122355 };
23132356 // size bar
2314− let focus_idx = rows
2315− .iter()
2316− .position(|m| m.commit().map(|x| x.is_focus).unwrap_or(false));
2317− let is_focus_parent = focus_idx
2318− .map(|fi| {
2319− rows[..fi].iter().any(|p| {
2320− p.children()
2321− .iter()
2322− .any(|ch| ch.uid() == rows[fi].uid()) && p.uid() == n.uid()
2323− })
2324− })
2325− .unwrap_or(false);
2326− let is_focus_child = focus_idx
2327− .map(|fi| rows[fi].children().iter().any(|ch| ch.uid() == n.uid()))
2328− .unwrap_or(false);
23292357 let show_bar = opts.detail >= 1
2330− && c.map(|x| opts.detail >= 2 || x.is_focus || is_focus_parent || is_focus_child)
2358+ && c.map(|x| opts.detail >= 2 || x.is_focus || focus_adjacent)
23312359 .unwrap_or(false);
23322360 let bar = if show_bar {
23332361 c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
@@ -2392,6 +2420,7 @@ fn build_row_text(
23922420#[allow(clippy::too_many_arguments)]
23932421fn draw_rows(
23942422 rows: &[&Display],
2423+ index: &RowIndex,
23952424 placements: &[Placement],
23962425 opts: &TreeOptions,
23972426 pal: &Palette,
@@ -2403,8 +2432,15 @@ fn draw_rows(
24032432 let rail_chars = 2 * lanes_n;
24042433 // each row's rails, drawn once; a run row's go on past the rails area
24052434 // when its count does not fit
2406− let rails: Vec<(Vec<char>, Vec<bool>)> = (0..rows.len())
2407− .map(|idx| rail_row(rows, placements, idx, lanes_n, opts))
2435+ let mut rail_state = vec![None; lanes_n];
2436+ let rails: Vec<(Vec<char>, Vec<bool>)> = rows
2437+ .iter()
2438+ .zip(placements)
2439+ .map(|(n, p)| {
2440+ let row = rail_row(n, p, &rail_state, lanes_n, opts);
2441+ advance_rail_state(&mut rail_state, n, p);
2442+ row
2443+ })
24082444 .collect();
24092445 // id column width: 4-char min prefix plus the `@` literal prefix, and
24102446 // what a run count writes into it past the rails area and the space
@@ -2423,8 +2459,8 @@ fn draw_rows(
24232459 .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
24242460 // build row texts
24252461 let mut texts: Vec<RowText> = Vec::new();
2426− for n in rows {
2427− texts.push(build_row_text(n, rows, opts, pal, with_focus, prefix_len, id_w));
2462+ for (i, n) in rows.iter().enumerate() {
2463+ texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
24282464 }
24292465 // Columns 4–8 start at the same offset on every row (§Step 4). The size
24302466 // bar's column is there when any row draws a bar — ` ▅`, two columns —
@@ -2512,7 +2548,11 @@ fn draw_rows(
25122548 let lab_off = msg_col + msg_w + 2;
25132549 let meta_off = if label_col { lab_off + lab_w + 2 } else { lab_off };
25142550 let mut lines: Vec<String> = Vec::new();
2551+ // Detail lines use the state below their row. Advance it once per row,
2552+ // as above, rather than replaying the preceding history.
2553+ rail_state.fill(None);
25152554 for (idx, n) in rows.iter().enumerate() {
2555+ advance_rail_state(&mut rail_state, n, &placements[idx]);
25162556 let t = &texts[idx];
25172557 let mut line = std::mem::take(&mut heads[idx]);
25182558 if n.commit().is_some() {
@@ -2548,7 +2588,7 @@ fn draw_rows(
25482588 && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
25492589 {
25502590 let c = n.commit().unwrap();
2551− let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
2591+ let (dchars, dlane0) = detail_rail_row(&rail_state);
25522592 let mut dline = String::new();
25532593 dline.push_str(" ");
25542594 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
@@ -2731,54 +2771,37 @@ fn append_legend(
27312771 }
27322772}
27332773
2734−/// rails state below row `upto` (exclusive): which lanes hold rails, and which
2735−/// of those are lane-0
2736−fn rail_state_below(
2737− rows: &[&Display],
2738− placements: &[Placement],
2739− upto: usize,
2740− lanes_n: usize,
2741−) -> Vec<Option<bool>> {
2742− let mut state: Vec<Option<bool>> = vec![None; lanes_n]; // Some(lane==0)
2743− for (j, m) in rows.iter().enumerate().take(upto) {
2744− let p = &placements[j];
2745− if let Some((src, clears)) = p.fork {
2746− if clears {
2747− state[src] = None;
2748− }
2749− }
2750− if let Some(l) = p.ends {
2751− state[l] = None;
2752− }
2753− if let Some(l) = p.lane {
2754− state[l] = if m.children().is_empty() {
2755− None
2756− } else {
2757− Some(l == 0)
2758− };
2759− }
2760− for &(l, _) in &p.reservations {
2761− state[l] = Some(false);
2774+/// Update the rails above a row to the rails below it. Each occupied lane
2775+/// records whether it belongs to lane 0.
2776+fn advance_rail_state(state: &mut [Option<bool>], n: &Display, p: &Placement) {
2777+ if let Some((src, clears)) = p.fork {
2778+ if clears {
2779+ state[src] = None;
27622780 }
27632781 }
2764− state
2782+ if let Some(l) = p.ends {
2783+ state[l] = None;
2784+ }
2785+ if let Some(l) = p.lane {
2786+ state[l] = if n.children().is_empty() { None } else { Some(l == 0) };
2787+ }
2788+ for &(l, _) in &p.reservations {
2789+ state[l] = Some(false);
2790+ }
27652791}
27662792
2767−/// rails characters for the row of node `idx` (§Step 4, the character table).
2793+/// Rails characters for one node's row (§Step 4, the character table).
27682794/// Returns the characters and, per character, whether it belongs to lane 0.
27692795/// On a run row they go on past the rails area when its count does not fit.
27702796fn rail_row(
2771− rows: &[&Display],
2772− placements: &[Placement],
2773− idx: usize,
2797+ n: &Display,
2798+ pl: &Placement,
2799+ state: &[Option<bool>],
27742800 lanes_n: usize,
27752801 opts: &TreeOptions,
27762802) -> (Vec<char>, Vec<bool>) {
2777− let n = rows[idx];
2778− let pl = &placements[idx];
27792803 let mut chars = vec![' '; 2 * lanes_n];
27802804 let mut lane0 = vec![false; 2 * lanes_n];
2781− let state = rail_state_below(rows, placements, idx, lanes_n);
27822805 // horizontal segments: (start, end) in lane coordinates
27832806 let mut segments: Vec<(usize, usize)> = Vec::new();
27842807 if let Some((src, _)) = pl.fork {
@@ -2860,13 +2883,8 @@ fn rail_row(
28602883
28612884/// detail line (§Step 4): `│` in every lane that holds a rail below the focus
28622885/// row, including the focus's own lane if it has children
2863−fn detail_rail_row(
2864− rows: &[&Display],
2865− placements: &[Placement],
2866− idx: usize,
2867− lanes_n: usize,
2868−) -> (Vec<char>, Vec<bool>) {
2869− let state = rail_state_below(rows, placements, idx + 1, lanes_n);
2886+fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
2887+ let lanes_n = state.len();
28702888 let mut chars = vec![' '; 2 * lanes_n];
28712889 let mut lane0 = vec![false; 2 * lanes_n];
28722890 for i in 0..lanes_n {
src/repo.rs+38−1
@@ -4,7 +4,7 @@
44use crate::domain::{Backend, ROOT_ID};
55use crate::eval::Interp;
66use crate::value::{value_eq, Crash, Value};
7−use std::collections::{BTreeMap, BTreeSet};
7+use std::collections::{BTreeMap, BTreeSet, HashMap};
88
99/// navigate: refocus `repo` on the commit with change id `id` (§10 reference).
1010///
@@ -67,6 +67,43 @@ fn find_path(children: &[Value], id: &str, path: &mut Vec<usize>) -> Result<bool
6767 Ok(false)
6868}
6969
70+thread_local! {
71+ // One repository value, held while it is the key so its address cannot
72+ // be reused. Edits create a different record and invalidate the index.
73+ static COMMIT_INDEX: std::cell::RefCell<Option<(
74+ std::rc::Rc<crate::value::RecordMap>, Option<HashMap<String, Value>>,
75+ )>> = const { std::cell::RefCell::new(None) };
76+}
77+
78+/// The root of `by_id`, without allocating a zipper for every queried id.
79+/// A malformed tree uses the original walk: a commit found before a bad
80+/// sibling must still be readable, and duplicate ids keep their first match.
81+pub fn commit_by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
82+ if let Value::Record(key) = repo {
83+ let indexed = COMMIT_INDEX.with(|cache| {
84+ let mut cache = cache.borrow_mut();
85+ if !cache.as_ref().is_some_and(|(k, _)| std::rc::Rc::ptr_eq(k, key)) {
86+ let index = (|| -> Result<HashMap<String, Value>, Crash> {
87+ let mut index = HashMap::new();
88+ let mut pending = vec![top_of(repo)?];
89+ while let Some(tree) = pending.pop() {
90+ let commit = tree.field("root")?;
91+ index.entry(id_of(&commit)?).or_insert(commit);
92+ pending.extend(tree.field("children")?.as_list()?.iter().rev().cloned());
93+ }
94+ Ok(index)
95+ })().ok();
96+ *cache = Some((key.clone(), index));
97+ }
98+ cache.as_ref().unwrap().1.as_ref().map(|index| index.get(id).cloned())
99+ });
100+ if let Some(found) = indexed {
101+ return Ok(found);
102+ }
103+ }
104+ by_id(repo, id)?.map(|r| r.field("root")).transpose()
105+}
106+
70107/// compare a commit's id without copying it out
71108fn id_is(commit: &Value, id: &str) -> Result<bool, Crash> {
72109 match commit.field("id")? {
src/value.rs+21
@@ -59,6 +59,11 @@ impl ListVal {
5959 pub fn identity(&self) -> (*const Vec<Value>, usize) {
6060 (Rc::as_ptr(&self.items), self.start)
6161 }
62+
63+ /// Observe the shared storage without keeping a cached list alive.
64+ pub(crate) fn weak_storage(&self) -> std::rc::Weak<Vec<Value>> {
65+ Rc::downgrade(&self.items)
66+ }
6267}
6368
6469impl std::ops::Deref for ListVal {
@@ -759,6 +764,22 @@ pub struct Env {
759764 frame: Option<Rc<Frame>>,
760765}
761766
767+/// A large acyclic let can have one frame per binding. Release unique
768+/// frames iteratively, keeping the next parent alive while its child's
769+/// values drop, so closures capturing earlier frames cannot recurse down
770+/// that chain either (§4.1).
771+impl Drop for Env {
772+ fn drop(&mut self) {
773+ let mut next = self.frame.take();
774+ while let Some(frame) = next {
775+ match Rc::try_unwrap(frame) {
776+ Ok(mut frame) => next = frame.parent.frame.take(),
777+ Err(_) => break,
778+ }
779+ }
780+ }
781+}
782+
762783pub struct Frame {
763784 kind: FrameKind,
764785 parent: Env,
tests/eval.rs+89
@@ -236,6 +236,95 @@ fn lexical_scoping() {
236236 );
237237}
238238
239+#[test]
240+fn nonrecursive_local_helpers_release_their_captures() {
241+ let (mut i, cfg) = make_interp();
242+ for (src, want) in [
243+ (
244+ "foldl (\\a n -> let step = \\x -> if sentinel == sentinel then x + n else 0 in step a) 0 (range 0 100)",
245+ Value::int(4950),
246+ ),
247+ (
248+ "(let step = \\_ -> sentinel; fail = crash (step 0) in fail) or true",
249+ Value::Bool(true),
250+ ),
251+ ] {
252+ // No global can hold this later-added value. A let cell that owns a
253+ // closure capturing itself used to keep the entire environment alive,
254+ // even after its scalar result (or caught crash) was discarded.
255+ let tracked = Rc::new("tracked environment".to_string());
256+ let weak = Rc::downgrade(&tracked);
257+ let env = i.global_env().extend(vec![("sentinel".into(), Value::Text(tracked))]);
258+ let expr = Rc::new(parse_expr(src, Rc::new(cfg.global_names.clone())).unwrap());
259+ let got = i.eval(&expr, &env).unwrap();
260+ assert!(value_eq(&got, &want).unwrap());
261+ drop(got);
262+ drop(env);
263+ assert!(weak.upgrade().is_none(), "local helper retained its environment: {}", src);
264+ }
265+}
266+
267+#[test]
268+fn escaping_helpers_keep_their_captures_until_they_are_dropped() {
269+ let (mut i, cfg) = make_interp();
270+ let tracked = Rc::new("captured".to_string());
271+ let weak = Rc::downgrade(&tracked);
272+ let env = i.global_env().extend(vec![("sentinel".into(), Value::Text(tracked))]);
273+ // The source's forward reference is put after its dependency; the
274+ // returned function must still capture that earlier helper.
275+ let src = "let second = \\x -> first x; first = \\x -> sentinel in second";
276+ let expr = Rc::new(parse_expr(src, Rc::new(cfg.global_names.clone())).unwrap());
277+ let f = i.eval(&expr, &env).unwrap();
278+ drop(env);
279+ assert!(weak.upgrade().is_some());
280+ let got = i.apply(f.clone(), Value::int(0)).unwrap();
281+ assert_eq!(got.as_text().unwrap(), "captured");
282+ drop(got);
283+ drop(f);
284+ assert!(weak.upgrade().is_none());
285+
286+ // A genuinely recursive group still needs its frame after the block's
287+ // body returns; clearing its cell at that point would break this call.
288+ let f = ok(
289+ &mut i,
290+ &cfg,
291+ "let even = \\n -> if n == 0 then true else odd (n - 1); odd = \\n -> if n == 0 then false else even (n - 1) in even",
292+ );
293+ assert!(value_eq(&i.apply(f, Value::int(11)).unwrap(), &Value::Bool(false)).unwrap());
294+}
295+
296+#[test]
297+fn shared_literal_buffers_preserve_caught_crashes_and_evaluation_order() {
298+ let (mut i, cfg) = make_interp();
299+ check!(i, cfg, "[1 (head [] or 2) 3]", Value::list(vec![Value::int(1), Value::int(2), Value::int(3)]));
300+ check!(i, cfg, "[1 (crash \"late\") 3] or [9]", Value::list(vec![Value::int(9)]));
301+ let record = Value::record(&[("a", Value::int(1)), ("b", Value::int(2)), ("c", Value::int(3))]);
302+ check!(i, cfg, "{ a = 1, b = head [] or 2, c = 3 }", record.clone());
303+ check!(i, cfg, "({ a = 1, b = 0, c = 0 }) { b = head [] or 2, c = 3 }", record);
304+ check!(i, cfg, "({ a = 1, b = crash \"late\" }) or 9", Value::int(9));
305+ check!(i, cfg, "(({ a = 0, b = 0 }) { a = 1, b = crash \"late\" }) or 9", Value::int(9));
306+ for src in [
307+ "[(crash \"first\") (crash \"second\")]",
308+ "{ a = crash \"first\", b = crash \"second\" }",
309+ "({ a = 0, b = 0 }) { a = crash \"first\", b = crash \"second\" }",
310+ ] {
311+ assert!(crash(&mut i, &cfg, src).ends_with("first"));
312+ }
313+}
314+
315+#[test]
316+fn a_large_flat_let_releases_its_frames_without_native_recursion() {
317+ on_stack(2, || {
318+ use j::ast::Expr;
319+ let bindings = (0..30_000)
320+ .map(|n| (format!("binding{}", n), Rc::new(Expr::Bool(true))))
321+ .collect();
322+ let expr = Rc::new(Expr::Let(bindings, Rc::new(Expr::Bool(true))));
323+ let got = Interp::dummy().eval(&expr, &Env::empty()).unwrap();
324+ assert!(value_eq(&got, &Value::Bool(true)).unwrap());
325+ });
326+}
327+
239328#[test]
240329fn unbound_names_crash() {
241330 let (mut i, cfg) = make_interp();
tests/performance_lookup.rsnew file+255
@@ -0,0 +1,255 @@
1+//! Indexed lookups preserve the reference config's behavior on arbitrary
2+//! values admitted by its shallow contracts, as well as ordinary snapshots.
3+
4+use j::config;
5+use j::domain::{MemBackend, ROOT_ID};
6+use j::eval::Interp;
7+use j::value::{value_eq, BlobContent, BlobKind, BlobVal, Crash, Env, LazyBlob, Value};
8+use std::cell::Cell;
9+use std::rc::Rc;
10+
11+const CONFIG: &str = include_str!("../config.j");
12+
13+fn interpreter(src: &str) -> Interp {
14+ let cfg = config::load_config(src).unwrap();
15+ let mut i = Interp::new(Rc::new(MemBackend::new()), cfg.shapes.clone(), Env::empty());
16+ config::eval_config(&mut i, &cfg).unwrap();
17+ i
18+}
19+
20+fn previous_config() -> String {
21+ CONFIG
22+ .lines()
23+ .filter(|line| !line.starts_with("lookupCommit :") && !line.starts_with("lookupContent :"))
24+ .collect::<Vec<_>>()
25+ .join("\n")
26+ .replace("commitAt = lookupCommit", "commitAt = \\i repo -> (by i repo).root")
27+ .replace(
28+ "contentAt = lookupContent",
29+ "contentAt = \\p s -> (head (entryAt p s)).content or blob \"\"",
30+ )
31+}
32+
33+fn call(i: &mut Interp, name: &str, a: Value, b: Value) -> Result<Value, Crash> {
34+ let f = i.global_env().lookup(name).unwrap();
35+ let f = i.apply(f, a)?;
36+ i.apply(f, b)
37+}
38+
39+fn path(name: &str) -> Value {
40+ Value::list(vec![Value::text(name)])
41+}
42+
43+fn entry(path: Value, content: Value) -> Value {
44+ Value::record(&[("path", path), ("content", content)])
45+}
46+
47+fn file(name: &str, content: &str) -> Value {
48+ entry(path(name), BlobVal::text_blob(content))
49+}
50+
51+fn assert_same(actual: Result<Value, Crash>, expected: Result<Value, Crash>, case: &str) {
52+ match (actual, expected) {
53+ (Ok(a), Ok(b)) => assert!(value_eq(&a, &b).unwrap(), "{case}: {a:?} != {b:?}"),
54+ (Err(a), Err(b)) => assert_eq!(a.msg, b.msg, "{case}"),
55+ (a, b) => panic!("{case}: {a:?} != {b:?}"),
56+ }
57+}
58+
59+#[test]
60+fn indexed_content_matches_original_for_duplicates_and_malformed_entries() {
61+ let mut indexed = interpreter(CONFIG);
62+ let mut original = interpreter(&previous_config());
63+ let function = indexed.global_env().lookup("id").unwrap();
64+ let function_path = Value::list(vec![function]);
65+ let cases = vec![
66+ ("first duplicate", path("a"), vec![file("a", "first"), file("a", "second")]),
67+ ("absent", path("missing"), vec![file("a", "first")]),
68+ (
69+ "first duplicate has no content",
70+ path("a"),
71+ vec![Value::record(&[("path", path("a"))]), file("a", "second")],
72+ ),
73+ (
74+ "first duplicate has wrong content kind",
75+ path("a"),
76+ vec![entry(path("a"), Value::int(1)), file("a", "second")],
77+ ),
78+ (
79+ "irrelevant content is not read",
80+ path("a"),
81+ vec![Value::record(&[("path", path("b"))]), file("a", "first")],
82+ ),
83+ (
84+ "later missing path still crashes the filter",
85+ path("a"),
86+ vec![file("a", "first"), Value::record(&[("content", BlobVal::text_blob("bad"))])],
87+ ),
88+ ("later non-record", path("a"), vec![file("a", "first"), Value::int(7)]),
89+ (
90+ "non-text path still compares structurally",
91+ Value::list(vec![Value::int(1)]),
92+ vec![entry(Value::list(vec![Value::int(1)]), BlobVal::text_blob("integer path"))],
93+ ),
94+ (
95+ "later path of another kind compares false",
96+ path("a"),
97+ vec![file("a", "first"), entry(Value::int(7), BlobVal::text_blob("unused"))],
98+ ),
99+ (
100+ "function path equality crashes",
101+ function_path.clone(),
102+ vec![entry(function_path.clone(), BlobVal::text_blob("unused"))],
103+ ),
104+ (
105+ "function path with different length is unequal",
106+ Value::list(vec![]),
107+ vec![entry(function_path.clone(), BlobVal::text_blob("unused"))],
108+ ),
109+ (
110+ "function and text path components are unequal",
111+ path("a"),
112+ vec![file("a", "first"), entry(function_path, BlobVal::text_blob("unused"))],
113+ ),
114+ ];
115+ for (case, path, entries) in cases {
116+ let files = Value::list(entries);
117+ let expected = call(&mut original, "contentAt", path.clone(), files.clone());
118+ // The second call exercises cached valid and malformed snapshots.
119+ for _ in 0..2 {
120+ assert_same(
121+ call(&mut indexed, "contentAt", path.clone(), files.clone()),
122+ expected.clone(),
123+ case,
124+ );
125+ }
126+ }
127+}
128+
129+#[test]
130+fn content_index_distinguishes_shared_tail_and_survives_eviction() {
131+ let mut i = interpreter(CONFIG);
132+ let whole = Value::list(vec![file("a", "first"), file("a", "second")]);
133+ let Value::List(entries) = &whole else { unreachable!() };
134+ let tail = Value::List(entries.skip(1));
135+ let third = Value::list(vec![file("a", "third")]);
136+ let fourth = Value::list(vec![file("a", "fourth")]);
137+ for (files, expected) in [
138+ (&whole, "first"), (&tail, "second"), (&whole, "first"),
139+ (&third, "third"), (&fourth, "fourth"), (&tail, "second"), (&whole, "first"),
140+ ] {
141+ let got = call(&mut i, "contentAt", path("a"), files.clone()).unwrap();
142+ assert!(value_eq(&got, &BlobVal::text_blob(expected)).unwrap());
143+ }
144+}
145+
146+#[test]
147+fn content_index_neither_reads_blob_bytes_nor_retains_snapshot() {
148+ let mut i = interpreter(CONFIG);
149+ let reads = Rc::new(Cell::new(0));
150+ let observed = reads.clone();
151+ let blob = Rc::new(BlobVal {
152+ kind: BlobKind::Regular,
153+ content: BlobContent::Lazy(Rc::new(LazyBlob::new("lazy-content".into(), move || {
154+ observed.set(observed.get() + 1);
155+ Err(Crash::new("bytes must not be read by contentAt"))
156+ }))),
157+ });
158+ let weak = Rc::downgrade(&blob);
159+ let files = Value::list(vec![entry(path("a"), Value::Blob(blob))]);
160+ for _ in 0..2 {
161+ let got = call(&mut i, "contentAt", path("a"), files.clone()).unwrap();
162+ assert!(matches!(got, Value::Blob(_)));
163+ }
164+ assert_eq!(reads.get(), 0);
165+ drop(files);
166+ assert!(weak.upgrade().is_none(), "the index retained the snapshot's blob");
167+}
168+
169+fn commit(id: &str, message: &str) -> Value {
170+ Value::record(&[
171+ ("id", Value::Id(Rc::new(id.into()))),
172+ ("message", Value::text(message)),
173+ ("labels", Value::list(vec![])),
174+ ("files", Value::list(vec![])),
175+ ])
176+}
177+
178+fn node(root: Value, children: Vec<Value>) -> Value {
179+ Value::record(&[("root", root), ("children", Value::list(children))])
180+}
181+
182+fn repo(root: Value, children: Value) -> Value {
183+ Value::record(&[("root", root), ("children", children), ("context", Value::list(vec![]))])
184+}
185+
186+fn lookup(i: &mut Interp, id: &str, repo: &Value) -> Result<Value, Crash> {
187+ call(i, "commitAt", Value::Id(Rc::new(id.into())), repo.clone())
188+}
189+
190+#[test]
191+fn commit_index_matches_navigation_from_every_focus_and_after_edits() {
192+ let mut indexed = interpreter(CONFIG);
193+ let mut original = interpreter(&previous_config());
194+ let whole = repo(
195+ commit(ROOT_ID, "root"),
196+ Value::list(vec![
197+ node(commit("kaaaaaaa", "a"), vec![node(commit("kccccccc", "c"), vec![])]),
198+ node(commit("kbbbbbbb", "b"), vec![]),
199+ ]),
200+ );
201+ let ids = [ROOT_ID, "kaaaaaaa", "kbbbbbbb", "kccccccc"];
202+ for focus in ids {
203+ let focused = j::repo::by_id(&whole, focus).unwrap().unwrap();
204+ for id in ids.into_iter().chain(std::iter::once("kmissing")) {
205+ for _ in 0..2 {
206+ assert_same(lookup(&mut indexed, id, &focused), lookup(&mut original, id, &focused), id);
207+ }
208+ }
209+ }
210+ let edited = repo(commit(ROOT_ID, "edited root"), whole.field("children").unwrap());
211+ for current in [&whole, &edited, &whole] {
212+ assert_same(
213+ lookup(&mut indexed, ROOT_ID, current),
214+ lookup(&mut original, ROOT_ID, current),
215+ "record identity after edit",
216+ );
217+ }
218+}
219+
220+#[test]
221+fn commit_index_preserves_first_duplicate_and_early_hits_in_malformed_trees() {
222+ let mut indexed = interpreter(CONFIG);
223+ let mut original = interpreter(&previous_config());
224+ let cases = [
225+ repo(commit(ROOT_ID, "root"), Value::list(vec![
226+ node(commit("kaaaaaaa", "first"), vec![node(commit("kaaaaaaa", "nested duplicate"), vec![])]),
227+ node(commit("kaaaaaaa", "second"), vec![]),
228+ ])),
229+ repo(commit(ROOT_ID, "root"), Value::list(vec![node(commit("kaaaaaaa", "a"), vec![]), Value::int(7)])),
230+ repo(commit(ROOT_ID, "root"), Value::int(7)),
231+ repo(commit(ROOT_ID, "root"), Value::list(vec![
232+ Value::record(&[("root", commit("kaaaaaaa", "a")), ("children", Value::int(7))]),
233+ node(commit("kbbbbbbb", "b"), vec![]),
234+ ])),
235+ ];
236+ for current in cases {
237+ for id in [ROOT_ID, "kaaaaaaa", "kbbbbbbb", "kmissing"] {
238+ for _ in 0..2 {
239+ assert_same(lookup(&mut indexed, id, &current), lookup(&mut original, id, &current), id);
240+ }
241+ }
242+ }
243+}
244+
245+#[test]
246+fn existing_configs_keep_their_original_lookup_definitions() {
247+ let mut i = interpreter(&previous_config());
248+ assert!(i.global_env().lookup("lookupCommit").is_none());
249+ assert!(i.global_env().lookup("lookupContent").is_none());
250+ let original_repo = repo(commit(ROOT_ID, "old config"), Value::list(vec![]));
251+ let got = lookup(&mut i, ROOT_ID, &original_repo).unwrap();
252+ assert_eq!(got.field("message").unwrap().as_text().unwrap(), "old config");
253+ let got = call(&mut i, "contentAt", path("a"), Value::list(vec![file("a", "old config")])).unwrap();
254+ assert!(value_eq(&got, &BlobVal::text_blob("old config")).unwrap());
255+}
tests/tree.rs+31
@@ -1246,6 +1246,37 @@ fn minted_commits_come_after_stored_ones() {
12461246 }
12471247}
12481248
1249+#[test]
1250+fn equal_times_follow_preorder_when_a_descendant_becomes_ready() {
1251+ // The descendant becomes ready after the second sibling, but precedes
1252+ // it in preorder. Ties use that order, not queue insertion or commit id,
1253+ // including when both nodes have a minted commit's infinite time.
1254+ for stored in [false, true] {
1255+ let root = commit(ROOT_ID, "", &[], vec![]);
1256+ let base = own_commit("kppppppp", "base", &[]);
1257+ let first = subtree(
1258+ own_commit("kzzzzzzz", "first", &[]),
1259+ vec![subtree(own_commit("kyyyyyyy", "descendant", &[]), vec![])],
1260+ );
1261+ let second = subtree(own_commit("kaaaaaaa", "second", &[]), vec![]);
1262+ let repo = repo_of(root, vec![subtree(base, vec![first, second])], None);
1263+ let mut metas = vec![meta(ROOT_ID, "R", 1), meta("kppppppp", "A", 2)];
1264+ if stored {
1265+ metas.extend(["kzzzzzzz", "kyyyyyyy", "kaaaaaaa"].map(|id| meta(id, "A", 3)));
1266+ }
1267+ assert_eq!(
1268+ row_shapes(repo, metas),
1269+ shapes(&[
1270+ ("○", "@kppp base"),
1271+ ("├─○", "@kzzz first"),
1272+ ("│ ○", "@kyyy descendant"),
1273+ ("○", "@kaaa second"),
1274+ ]),
1275+ "stored: {}", stored,
1276+ );
1277+ }
1278+}
1279+
12491280#[test]
12501281fn lane_inheritance_follows_row_order_not_sibling_order() {
12511282 // `rebase` appends the moved commit to its new parent's children but it