Buffer-local options
Companion to mode-architecture.md §6 and design.md §5.12. Owns the what and why of per-buffer option values; sequencing lives in docs/dev/operations/slice-plans/buffer-local-options.md.
1. Problem
:set writes to the global config layer. Every buffer sees the same value unless a mode contributes an OptionOverrideSet. There is no user-facing way to say "wrap=true only in this buffer" without writing a dedicated mode — the vim :setlocal / emacs make-local-variable concept has no command surface yet.
The gap surfaced concretely in D.0b: :set scrollbind=true binds all panes simultaneously because it writes the global layer; vim users expect it to apply only to the current window.
2. Design choice: universal buffer-local layer (Option B)
No scope declaration per option. Every option is potentially buffer-local. The user chooses :set (global) vs :setlocal (current buffer) at call-site.
Rationale: The options! macro requires no change. Plugins and modes can seed buffer-local values without touching the global config. The origin system makes every effective value auditable without a per-option taxonomy. Vim's :setlocal / :setglobal vocabulary is preserved as the user-facing surface; the scope semantics are determined by which command is used, not by a fixed option attribute.
Rejected: per-option #[scope(WindowLocal)] declarations (Option A) — creates a permanent per-option maintenance burden and the vim scope taxonomy has known historical mistakes. Option C (hybrid with hints) adds machinery over Option B with no capability gain for v1.
3. Resolution stack (updated)
Priority, highest to lowest:
1. Modal-state override — e.g. Insert-mode forces wrap=false for
the command line buffer
2. Buffer-local explicit — :setlocal foo=bar ← this design
3. Active minor modes — OverridePriority breaks ties
4. Major mode — Mode::options() contribution
5. Global config — :set foo=bar, init.toml, user TOML
6. Built-in default — option's declared default value
Layers 1, 3, 4 already contributed via OptionOverrideSet passed to Resolver::resolve_into. Layer 2 is now the buffer_local_overrides map already stubbed on Editor; it feeds the resolver as the second-highest layer. Layer 5 is the ConfigRegistry. Layer 6 is bootstrapped once at startup.
4. Data model
4.1 Editor::buffer_local_overrides
pub buffer_local_overrides: HashMap<BufferId, OptionOverrideSet>,
Already declared on Editor. recompute_options_for_buffer already reads it and passes it as layer 2 to the resolver. All that was missing was the write path (:setlocal) and the parse-without-write helper on ErasedOption.
4.2 ErasedOption::parse_to_erased
fn parse_to_erased(
&self,
value: &str,
) -> Result<Arc<dyn Any + Send + Sync>, String>;
Parses value against the option's OptionType and runs the validator, but does not write to the option's storage. Returns the typed value erased as Arc<dyn Any + Send + Sync>. Used by ConfigRegistry::parse_for_buffer_local to produce an OptionOverride without touching the global layer.
4.3 ConfigRegistry::parse_for_buffer_local
pub fn parse_for_buffer_local(
&self,
input: &str,
) -> Result<(TypeId, Arc<dyn Any + Send + Sync>, String), ConfigError>;
// ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^
// type_id erased parsed value canonical name
Parses an option spec string (same syntax as :set), validates, and returns the triple needed to construct an OptionOverride — without touching the global registry. ParsedSet::Query returns ConfigError::QueryNotAllowed; callers use do_set_local only for writes.
4.4 OptionOrigin
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OptionOrigin {
Default,
GlobalConfig, // :set
BufferLocal, // :setlocal
ModeContribution { mode_id: String },
// DirectoryLocal { path: PathBuf }, // future: .dir-locals.toml
// FileLocal, // future: modeline
}
Lives in lattice-config. ResolvedOptions entries carry an OptionOrigin alongside the erased value. The resolver writes GlobalConfig when seeding from the registry bootstrap, BufferLocal when writing from the buffer-local layer, and ModeContribution when writing from a mode override.
5. Command surface
:set [no]name[=value]
Unchanged. Writes to the global config layer.
:set name?
Returns name=<effective> (<origin>). Examples:
scrollbind=true (buffer-local)
wrap=false (global)
tabstop=4 (mode: rust-mode)
:setlocal [no]name[=value] aliases: :sl
Writes to buffer_local_overrides for the active buffer. Triggers recompute_options_for_buffer for that buffer and the normal apply_option_cascade on the canonical name.
:setlocal name?
Returns name=<local> (buffer-local) if a local override exists, or name not set locally (global: <global-value>) if not.
:setglobal [no]name[=value] aliases: :sg
Writes to the global config layer only; does not touch the buffer- local override for any buffer. Useful for changing the default without overriding an existing local value.
:setglobal name?
Returns the global config value regardless of any buffer-local overrides.
:setlocal name&
Clears the buffer-local override for name, reverting to the next-lower layer. :setlocal & clears all local overrides for the active buffer. Mirrors vim's setlocal name&vim syntax.
6. Cascade routing
:setlocal triggers recompute_options_for_buffer(active_buffer) directly, then calls apply_option_cascade scoped to the active buffer. :set continues the current global-broadcast behaviour (all buffers' resolved caches refreshed). This is correct: a global change can affect any buffer; a buffer-local change only affects that buffer.
No changes to drain_option_changes plumbing are needed; the scope distinction is achieved by calling per-buffer vs global recompute.
7. Traceability
:set option? shows effective value + origin in parens. :setlocal option? shows the local value or "not set locally (global: X)". :describe-buffer lists all buffer-local overrides with canonical names. Future :options view (BL.3) shows the full catalog with effective, global, local, and origin for every registered option.
8. Interaction with modes
Mode contributions (layers 3/4) win over :setlocal (layer 2). A mode contributing ReadOnly = true with OverridePriority::High cannot be overridden by :setlocal read-only=false. For non-critical Normal-priority contributions, a future high-priority buffer-local override could force through; BL.1 does not expose priority in :setlocal — all writes use Normal.