Fold architecture
Authoritative design for Lattice's fold engine: the provider abstraction underneath :set foldmethod=, how multiple fold sources compose into one per-buffer fold list, and the contracts that keep the z* grammar surface source-agnostic.
This document is a companion to design.md (§5.2 modal engine, §5.9 buffer model), to diff-system.md (§6.5 hunk foldability — first overlay consumer), and to multibuffer-views.md (§6.5 excerpt + file-boundary foldability — second and third overlay consumers).
1. The design goal
Folds in Lattice are range-based decoration over a buffer's line space. The user-facing grammar (za / zo / zc / zR / zM, :set foldmethod=, :set foldlevel=N, :foldopen / :foldclose) is intentionally fold-source- agnostic — it operates on Fold entries without caring who produced them.
That agnosticism is the load-bearing property. It is what lets the diff subsystem add hunk folds, the multibuffer subsystem add excerpt and file-boundary folds, and a plugin add a custom fold source — all without touching the z* arms or growing new ex-commands.
Two saved invariants frame the choices:
Don't add features beyond what the task requires. --
CLAUDE.md, design discipline rule
Buffers must not have kind-specific logic. -- saved feedback
feedback_buffers_no_special_case.md
The first rules out per-source :hunk-fold / :excerpt-fold ex-commands. The second rules out branching recompute_folds on BufferKind. Both push the same way: one substrate, many providers, uniform downstream.
2. The shape
┌──────────────────────────────────────┐
│ FoldRegistry (per Editor) │
│ │
│ primaries: HashMap<FoldMethod, P> │
│ overlays: Vec<O> │
└─────────────┬────────────────────────┘
│
┌───────────┴────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Primary provider │ │ Overlay provider │
│ (one runs) │ │ (all run) │
└──────────────────┘ └──────────────────┘
│ │
│ │
▼ ▼
Manual / Indent / Hunk + Unchanged
Markdown / Syntax / (D.3.f / D-fix.5),
Lsp Excerpt (M.7),
FileBoundary (M.8),
plugin overlays
:set foldmethod= picks one primary. Overlays always contribute. The registry merges, dedupes, and carries closed-state across recomputes.
3. The data model
3.1 Fold
Unchanged from today (lattice-core::Fold): a tuple of (start_line, end_line, closed, identity). identity is the cache key that survives recompute — adding a provider doesn't change the shape of a fold, only who emits it.
3.2 ProviderKind
pub enum ProviderKind {
Primary, // mutually exclusive; :set foldmethod= picks one
Overlay, // always composes
}
3.3 ProviderId
pub struct ProviderId(pub u64);
Stable identifier for a registered provider. Used by the registry for lookup, and by Fold::identity so the recompute can attribute a fold back to its provider when diagnostics need it. Two distinct providers must produce distinct ProviderIds; a single provider produces the same id across recomputes.
3.4 FoldProvider trait
Lives in lattice-host (not lattice-core) because providers typically need host-side state (SyntaxSnapshot, LSP fold cache, HunkIndex). lattice-core stays free of host dependencies; the trait sits a layer up.
pub struct FoldContext<'a> {
pub buffer: &'a Buffer,
pub buffer_id: BufferId,
pub path: Option<&'a Path>,
pub syntax: Option<&'a SyntaxSnapshot>,
pub lsp_folds: Option<&'a [Fold]>,
pub diff_hunks: Option<&'a HunkIndex>,
}
pub trait FoldProvider: Send + Sync {
fn id(&self) -> ProviderId;
fn kind(&self) -> ProviderKind;
fn compute(&self, ctx: &FoldContext<'_>) -> Vec<Fold>;
}
Providers are pure functions of the context — no back-references to Editor. The dispatcher (the registry itself) pre-loads what each provider needs, then drives the compute. This keeps providers cheap to test in isolation and keeps the threading story simple (no &mut Editor inside compute paths).
3.5 FoldRegistry
pub struct FoldRegistry {
primaries: HashMap<FoldMethod, Arc<dyn FoldProvider>>,
overlays: Vec<Arc<dyn FoldProvider>>,
}
Owned by Editor. Constructed at editor boot with the five built-in Primary providers (one per FoldMethod variant) already registered. Overlay providers are added/removed as their subsystems open/close — HunkFoldProvider registers when DiffSubsystem::open_session runs, deregisters when drop_session runs.
4. Recompute algorithm
Editor::recompute_folds() (called on document edit, after diff publish, after LSP fold cache update, on :set foldmethod= change):
- Build
FoldContextby gathering inputs that any registered provider might need: the document snapshot, the path, the syntax snapshot (if any), the LSP fold cache (if any), the diff hunks (if a session is open). - Look up the primary provider keyed on
self.foldmethod. Run it. (Manualreturnsvec![].) - Iterate
overlaysin registration order. Run each. - Concatenate the results into one
Vec<Fold>. - Carry over closed-state: for each new fold whose
identitymatches a fold in the previousself.folds, adopt the previousclosedflag. Falls back to(start_line, end_line)match whenidentityisNone. - Carry over user
zffolds (identity =None) from the previousself.foldsverbatim — primary and overlay providers don't emit them. - Sort: ascending
start_line, then descendingend_line(existing convention so larger enclosing folds sort before their children when start lines match). - Store as
self.folds.
The merge is one pass — O(P + O + F) where P is the primary result size, O is the union of overlay result sizes, F is the previous self.folds size. At expected scales (P ≤ 200, O ≤ 100, F ≤ 300) the recompute fits inside the one-frame keystroke ceiling (8.3 ms at 120 Hz); bench gate enforces this.
5. Composition rules
Mostly the rules vim already uses, generalised:
- Smallest enclosing fold wins on
za. When two folds cover row R, repeatedzawalks innermost-to-outermost. Today's selection helpers (innermost_fold_idx,fold_to_close_at,outermost_fold_idxinapp/folds.rs) already implement this — they readself.foldswithout caring about source. zRopens every fold;zMcloses every fold. No source distinction.:set foldlevel=Nhonours nesting depth. The depth computation walksself.foldsand counts enclosing folds; source-agnostic.- Identity collisions resolve last-write-wins on closed-state. When two providers emit folds with the same
(start_line, end_line)(rare — would require a syntactic node coinciding exactly with a hunk), the later-registered provider'sclosedflag wins. Identity hashes are namespaced per provider so true identity collisions across providers don't occur in practice.
5.1 Fold-aware viewport scrolling
The host scroll model keys scroll on a source line and measures the visible window in display rows (Editor::bottom_anchored_scroll). Soft-wrap and virtual rows (excerpt headers, HUDs) only ever add display rows to a source line, so the historical 1-per-source-line cost held with an upward +segment_count term bolted on. A closed fold is the one construct that goes the other way: it removes rows — the whole body collapses onto the single visible head row.
So the bottom-anchor walk skips a closed fold's body entirely. It hops from a hidden line straight up to the fold head via FoldIndex::enclosing_closed_fold, counting the head as one row and the body as zero. Counting collapsed lines as one row each burns the row budget on invisible content and scrolls a buffer whose unfolded text already fits — j over a deeply-folded document jumping the viewport for no reason. The walk stays O(budget · log folds) regardless of how many lines a fold hides; nofoldenable / no closed folds degrade it to the historical per-line walk byte-for-byte.
5.2 Gutter marker rendering
Both renderers draw a fold marker on every foldable head row while foldenable is on — ▾ on an open (expanded) head, ▸ on a closed (collapsed) one — so the affordance is visible before you fold, not only after. The host answers the per-row question with FoldIndex::fold_start_kind_at(line) -> Option<FoldMarker> (Open / Closed / None); each renderer maps that to its glyph. The TUI's fold_glyph_for and the GPUI gutter-meta build call the same predicate, so the two peers never disagree on which rows carry a marker.
Placement. The marker sits between the line number and the code (… 99 ▸ code), not in a leading fold column — a separator space before the glyph and a trailing gap after it. Both gutters reserve that three-cell trailing slot unconditionally (even with nonumber), so toggling line numbers or folding a region never shifts the body column.
Colour is themed, muted by convention. The glyphs resolve through two theme elements — gutter.fold.open (default overlay, the dim gutter tone) and gutter.fold.closed (default subtext, the line-number tone, a step brighter so a collapsed fold reads as "content hidden here"). This follows the cross-editor convention: VS Code, Zed, JetBrains, Sublime, and Neovim all render fold controls as a low-emphasis gray, never an accent. A theme that leaves the elements unset falls back to the historical dim gutter colour, so the marker never vanishes. The ⋯ N lines collapsed summary (TUI today; GPUI parity pending) carries the rest of the "hidden content" signal via text rather than a loud glyph colour.
6. Grammar surface impact
None — from the provider refactor. That is the whole point. The z* family, the :foldopen / :foldclose ex-commands, the foldmethod= / foldlevel= options — all unchanged. The refactor is purely under-the-hood.
:set foldmethod= still parses to FoldMethod (the existing enum); the registry resolves the enum to its registered primary provider. Adding a new primary in the future means adding a FoldMethod variant and a FoldProvider impl; the enum stays the user-facing surface. Adding an overlay needs no enum or option work — the subsystem owns lifecycle.
(The one later feature that did add z-grammar — org-mode cycling — is §6.1. It adds keys/commands but no new provider, method, option, or Fold field.)
6.1 Org-mode visibility cycling
za toggles a fold between two states (open / closed). Org-mode cycling walks a heading — or the whole buffer — through three progressive states with one key. It is an operation over the fold list, not a new provider or FoldMethod: it lives host-side beside do_set_fold_state_at_cursor (za) / do_set_all_folds (zR/zM) as Editor::do_cycle_fold_at_cursor / do_cycle_folds_global, wired through the same AppEffect → Action → Editor::do_* path. Universal z* grammar (Builtin layer), so no mode owns it.
No new Fold state — hierarchy by containment, state by inference. This is the load-bearing design choice:
Foldis unchanged ({start_line, end_line, closed, identity}). There is no depth field. Parent/child is derived on demand by range containment —AisB's ancestor iffA.start ≤ B.start && B.end ≤ A.end— the same derivation:set foldlevel=Nalready does. Direct children of a fold are its descendants not contained by any other descendant; a leaf is a fold containing no other fold.- The cycle state is inferred from the current
closedflags, not stored. The local cycle reads the root + its descendants; the global cycle reads the whole list. Statelessness is the win: it needs no reconciliation across recompute (the providers re-emit folds every edit; a stored cycle-state would fight theidentitycarry-over), and it composes cleanly withza/zo/zR— a manual tweak just changes what the next cycle press infers, exactly like emacs.
The states (each is a closed-flag pattern the operation writes):
- Local (
z<Space>/:fold-cycle), on the innermost fold under the cursor (the "root"): FOLDED (root closed) → CHILDREN (root open, every descendant closed) → SUBTREE (root + descendants open). A leaf root degenerates to FOLDED ↔ open (a toggle), matching org. - Global (
z<Tab>/:fold-cycle-global), over every fold: OVERVIEW (all closed) → CONTENTS (each fold closed iff it is a leaf — structural headings open, leaf bodies folded) → SHOW-ALL (all open). With no nesting (a flat list) OVERVIEW and CONTENTS coincide, so the cycle degenerates to OVERVIEW ↔ SHOW-ALL.
z<Space> is contextual: when the cursor is not inside any fold it falls back to the global cycle, so one key serves both; z<Tab> is the explicit-global escape hatch (the contextual key does the local cycle while on a heading).
Outline navigation companion. zp / :fold-goto-parent (do_goto_parent_fold) moves the cursor up one level — emacs outline-up-heading — using the same containment derivation: the innermost fold containing the cursor, then the start of the innermost fold strictly containing that. It's a cursor move (no fold-state change), distinct from zj/zk (which step to a sibling fold edge), and wired the same AppEffect → Action → Editor::do_* way.
The one deviation from emacs. Because the providers emit subtree-spanning folds (a heading's fold covers its whole subtree, not just its body), CHILDREN / CONTENTS leave the heading's own intro text (above its first child) visible, where org hides it. Closing that gap would need the providers to emit a separate body fold per heading — a deliberate non-goal (more folds, more recompute) for a cosmetic difference.
Performance. The cycle is a one-shot per keypress, off the render path (a command, like za — not per-frame). Cost is O(n²) containment math over n = folds in the buffer, which §4 bounds at ≈≤300 even at extreme scale — microseconds, the same class as the existing innermost_fold_idx / outermost_fold_idx scans and the foldlevel depth walk. A fold op already forces a one-shot display-matrix recompute (fold elision) that dwarfs it. (If n ever justified it, the global is_leaf could drop to O(n log n) with a sort + stack; premature for ≤300.)
Rejected alternative — store depth / cycle-state on Fold. It would make the inference trivial but adds per-fold bookkeeping that must be recomputed and reconciled with identity on every provider re-emit, and a stored state desyncs from reality the moment the user runs a plain za. Heuristic #1: the genuinely-better long-term design is the stateless one; the derivation cost is negligible (above).
7. Plugin path (forward-looking)
A WIT-registered fold provider falls naturally out of this shape. The plugin's FoldProvider impl is a thin shim that calls into wasmtime; the registry treats it the same as a built-in. kind: Primary plugin providers register a new FoldMethod variant at boot (the enum gains an Extension arm carrying the plugin-supplied label); kind: Overlay plugin providers register/deregister around their lifecycle events. Both reuse the merge pipeline.
Plugin host enforcement (fuel limits, crash isolation, etc.) applies uniformly because compute() is a single typed call. No diff-aware or excerpt-aware plumbing on the plugin side.
8. Open questions
- Per-overlay enable flag. Should
:set diffopt-=folds(or similar) let a user disable hunk-fold overlay without closing the diff session? Probably yes for v1 — the option lives on the diff subsystem, not the fold engine. Deferred to D.3.f.1's decision moment. - Provider ordering deterministic vs. registration- order. Today's plan uses registration order. If two overlays produce identity collisions, that order determines who wins. Alphabetical-by-provider-id may be more deterministic; revisit if it bites.
- Async compute. All providers today are sync because the budget fits. LSP fold provider is async-fed (cache lookup is sync against an async-populated cache); hypothetical plugin providers that need async resolution will need a Promise-shaped variant. Deferred until a real consumer asks.
9. Slice plan
Sequencing lives in docs/dev/operations/slice-plans/fold-architecture.md; authoritative status per slice lives in docs/dev/operations/implementation.md. This fragment owns what and why; the slice plan owns when and in what order.
10. Testing strategy
- Unit tests on the registry: primary swap reproduces today's behaviour exactly (existing fold tests stay green); overlay add → recompute → fold appears; overlay drop → recompute → fold gone; closed-state survives primary swap; closed-state survives overlay re-emit; identity-collision last-write-wins resolves predictably.
- Per-provider tests (already exist for indent / markdown / syntax in
lattice-host::folds): preserved verbatim — the providers wrap today's pure functions. - End-to-end (D.3.f.1): open a file with a hunk,
zaon a row inside the hunk collapses it;zRreopens;:set foldmethod=indentthen:set foldmethod=syntaxswaps primaries without losing hunk overlay. - Bench (D.3.f.2):
fold_recompute_p99_usat 100 hunks overlaid on top of a syntax primary. CI gate catches registry-indirection regression. - Org-cycle (§6.1): on a nested parent + children fold set, the local cycle walks FOLDED → CHILDREN → SUBTREE → FOLDED via the
closed-flag patterns; a leaf root toggles; the global cycle walks OVERVIEW → CONTENTS → SHOW-ALL; off-foldz<Space>falls back to the global cycle. (Tests inlattice-ui-tui::app::folds.)
11. Risks
- Refactor blast radius.
recompute_folds()is called from many sites (document edit, foldmethod change, document open). Each site must still work after the refactor. Mitigation: the trait wraps the existingcompute_*_foldsfunctions unchanged; the dispatch shape stays the same; tests stay green. - Identity-hash collision across providers. A syntax fold and a hunk fold sharing
(start_line, end_line)could collide on identity if both providers hash the same inputs. Mitigation: providers namespace their identity hashes (hash(("hunk", start, end)),hash(("syntax", node_kind, line_text))); theProviderIdsalt makes accidental collision essentially impossible. - Overlay registration leaks. A subsystem that registers an overlay but never deregisters leaves stale folds. Mitigation: subsystem lifecycle owns registration —
DiffSubsystem::drop_sessionis the symmetric point. Editor close drains the registry.
12. Cross-references
lattice-core/src/folding.rs—Fold,FoldMethod,ProviderKind,ProviderId(data types only).lattice-host/src/fold_provider.rs(new) —FoldProvider,FoldContext,FoldRegistry.lattice-host/src/folds.rs— primary provider impls wrappingcompute_indent_folds/compute_markdown_folds/compute_syntax_folds.lattice-host/src/dispatch.rs—Editor::recompute_foldsdrives the registry; LSP fold cache plumbing stays put.do_cycle_fold_at_cursor/do_cycle_folds_global(§6.1) live here beside theza/zRhandlers.crates/lattice-ui-tui/src/app/folds.rs— unchanged for the provider model (readsself.foldssource-agnostically); also holds the org-cycle tests (§6.1).docs/user/folding.md— the user-facing folding guide, including the "Org-style cycling" section (§6.1's surface).docs/dev/architecture/diff-system.md§6.5 — first overlay consumer (hunk folds).docs/dev/architecture/multibuffer-views.md§6.5 — M.7 and M.8 overlay consumers (excerpt + file boundary).