1023 lines
51 KiB
Markdown
1023 lines
51 KiB
Markdown
# Config registry — framing (cross-cutting substrate)
|
||
|
||
pmacs has no unified configuration surface. Every setting that exists
|
||
today invented its own shape: a getter-when-nil function here, a raw
|
||
mutable Lua table there, a window field, a Rust preference struct with
|
||
its own epoch counter and wire channel. Nothing is discoverable, nothing
|
||
is validated centrally, and there is no way at all to say "this setting,
|
||
but only in this buffer."
|
||
|
||
That gap is the named blocker on five separate backlog items — the
|
||
per-buffer auto-pair toggle, language-aware indent, per-language comment
|
||
padding, per-project compile commands, and the tab-width duplication —
|
||
and `docs/side-quest-backlog.md` ranks it first on the north star for
|
||
exactly that reason.
|
||
|
||
This framing proposes a third registry alongside `CommandRegistry` and
|
||
`HookRegistry`, built to the rules those two already enforce, with two
|
||
scopes (global and buffer-local) and no wire surface.
|
||
|
||
Backlog: `docs/side-quest-backlog.md` — "Cross-cutting substrate",
|
||
north-star item 1. Not a numbered roadmap arc; it is the substrate the
|
||
roadmap keeps tripping over.
|
||
|
||
**Revision 1 — 2026-07-21.** Supersedes a withdrawn same-day draft.
|
||
Ideas carried forward from it, credited where they land: the
|
||
post-commit listener API with disposable handles (Q#CR6), `Live` vs
|
||
`StartupOnly` mutability (Q#CR10), owned-value/deep-copy storage
|
||
(Q#CR3), the two-epoch counters (Q#CR2), strict raw-table spec
|
||
validation (Q#CR3), and the LuaJIT-vs-lua54 integer-exactness
|
||
requirement (acceptance 6). Not carried forward, with reasons inline:
|
||
global-only scoping (Q#CR4), `editor.tab_width` as the proving adopter
|
||
(Q#CR13), snake_case names (Q#CR9), and Lua bindings inside
|
||
`lua_bindings/mod.rs` (Q#CR2).
|
||
|
||
**Revision 2 — 2026-07-21, review round 1.** Findings F1–F11.
|
||
- **F1 (the one that mattered):** "equal-value set is a true no-op"
|
||
contradicted `is_set`, and under the naive reading an equal-valued
|
||
buffer-local override stored nothing — so a later global set would
|
||
flip the very buffer the user had pinned, silently breaking the
|
||
flagship feature. Overrides are now **always stored**; only
|
||
`value_epoch` and listener dispatch key on effective-value change
|
||
(Q#CR2, Q#CR4, acceptance 11).
|
||
- **F2:** listener dispatch semantics pinned across scopes (Q#CR6).
|
||
- **F3:** GC-collected listeners dropped — no `MetaMethod::Gc` exists
|
||
anywhere in `mod.rs` (verified: zero matches), the compile-mode
|
||
precedent is explicit-dispose only, and GC timing differs across the
|
||
two Lua backends (Q#CR6).
|
||
- **F4:** the migration wrappers keep their legacy coercion (Q#CR8).
|
||
- **F5:** `StartupOnly` × `set_local` resolved — the combination is
|
||
rejected at define time (Q#CR10).
|
||
- **F6:** `string-list` dropped from the stage-1 vocabulary (Q#CR3).
|
||
- **F7:** `describe`'s `local` field renamed `buffer_local` — `local`
|
||
is a Lua keyword (Q#CR11).
|
||
- **F8:** the direct-remove leak is permanent-but-bounded with no
|
||
aliasing hazard, and now has an acceptance item (Q#CR5).
|
||
- **F9:** `get(name)` with no buffer resolves the global chain only
|
||
(Q#CR4).
|
||
- **F10:** builtin defines moved to their owning modules (Q#CR14).
|
||
- **F11:** all stale line anchors corrected against `7bc0c61`.
|
||
- **Bet 6 confirmed and retired** into ground truth, with a correction
|
||
to this doc's own "Init ordering" claim.
|
||
|
||
**Revision 3 — 2026-07-21, implementation round 1.** Two corrections
|
||
found while building the adopters, both against this document rather
|
||
than against the code.
|
||
- **Acceptance 30 vs 31 contradicted each other** — 31 forbade a
|
||
"per-frame lookup" that 30 required. Q#CR15's prohibition is about
|
||
render hot paths (per cell, per line, table construction), not one
|
||
O(1) scalar `get` per tick. Item 31 reworded.
|
||
- **`builtin/runtime/config.lua` does not exist** (Q#CR14). `pmacs.config`
|
||
installs from Rust before any runtime chunk, exactly as `pmacs.command`
|
||
and `pmacs.hook` do, so the chunk had nothing to hold — and the one
|
||
helper it might have held would have broken acceptance 9 by capturing
|
||
every builtin define's `SourceLocation` at `config.lua`. Acceptance 26
|
||
rewritten from a now-vacuous claim. Net effect: this arc touches
|
||
`src/editor.rs` zero times.
|
||
- **F5's rejection point was unimplementable as written** (Q#CR10).
|
||
Revision 2 had `define` rejecting `StartupOnly` + `set_local`, but
|
||
`define` cannot know about a future call and `StartupOnly` is legal
|
||
alone. Moved to `set_local`, where the combination actually manifests.
|
||
Acceptance 24 rewritten.
|
||
- The registry's `frozen` flag is driven from the existing
|
||
`InitCompleteFlag` at write time rather than by a new `editor.rs`
|
||
call, which is what keeps the zero-touch claim above true.
|
||
|
||
---
|
||
|
||
## Ground truth (as of `7bc0c61`)
|
||
|
||
### `src/config.rs` is not a config registry
|
||
|
||
Despite the name, `src/config.rs` (274 lines) is the `init.lua` *loader*
|
||
and nothing else: XDG config-dir resolution (`user_config_dir`,
|
||
`resolve_config_dir`), a `package.path` prepend so a user's config can
|
||
span files (`install_package_path`), and a non-fatal eval
|
||
(`load_user_config_at` — missing file, unreadable file, parse error and
|
||
runtime error are all survivable by contract). It stores no settings and
|
||
knows no setting names. The name is taken; that is a naming problem for
|
||
the new module, not a design one.
|
||
|
||
### The settings zoo — nine shapes, none of them shared
|
||
|
||
Every one of these is a real user-facing configuration surface at
|
||
`7bc0c61`. They agree on nothing:
|
||
|
||
| Surface | Shape | Storage | Validated? |
|
||
| --- | --- | --- | --- |
|
||
| `pmacs.async_config.frame_target_ms(ms)` (`async.lua:458`) | getter-when-nil fn | module-local upvalue | ad-hoc |
|
||
| `pmacs.async_config.default_max_batch(n)` (`async.lua:468`) | getter-when-nil fn | module-local upvalue | ad-hoc |
|
||
| `pmacs.autosave.interval_ms(ms)` (`autosave.lua:42`) | getter-when-nil fn | module-local upvalue | hand-rolled floor + NaN check |
|
||
| `pmacs.killring.max(n)` (`killring.lua:65`) | getter-when-nil fn | module-local upvalue | ad-hoc |
|
||
| `pmacs.editops.trim_on_save(on)` (`editops.lua:844`) | getter-when-nil fn | module-local upvalue | none |
|
||
| `pmacs.autosave.enable` (`autosave.lua:34`), `pmacs.recentf.enable` (`recentf.lua:21`), `pmacs.saveplace.enable` (`saveplace.lua:21`), `pmacs.session.desktop_mode` (`desktop.lua:19`) | boolean fn | module-local upvalue | `on ~= false` |
|
||
| `pmacs.lsp.config`, `pmacs.lsp.filetypes`, `pmacs.comment.strings`, `pmacs.pair.sets` (`pair.lua:40`) | raw mutable table | plain Lua | at read time, per-consumer |
|
||
| `pmacs.parse.shebangs` / `.filenames` / `.injection_aliases` (`syntax.lua:55`) | write-through proxy | Rust registry behind a proxy | at write |
|
||
| `pmacs.theme.set/merge`, `pmacs.gpu.set_font` (`font_pref.rs`), `pmacs.window.set_line_numbers` (`mod.rs:11254`), `pmacs.statusline.register` (`statusline.rs`) | Rust binding | Rust struct + epoch + wire channel | at the binding |
|
||
|
||
Consequences worth stating plainly: no enumeration (you cannot ask what
|
||
is configurable), no `describe`, no type discipline, no change
|
||
notification, and **no scoping** — every entry above is global except
|
||
`line_numbers`, which is window-local because a window happened to be
|
||
the convenient place to hang it.
|
||
|
||
### Two registries already do this correctly
|
||
|
||
`src/command.rs` (320 lines) and `src/hook.rs` (627 lines) are the
|
||
precedent, and they agree with each other:
|
||
|
||
- `HashMap<String, T>` by name plus a `Vec<String>` insertion order for
|
||
stable listing.
|
||
- **R42** — a non-empty `description` is mandatory at define time
|
||
(`CommandError::MissingDescription`, `HookError::MissingDescription`).
|
||
- **R50** — spec-table keys are checked against a closed set, so a typo
|
||
is an error, not a silent no-op (`UnknownField`, carrying the
|
||
supported-key list in the message).
|
||
- Duplicate names are rejected, never silently overwritten — "silent
|
||
overwrite makes refactoring bugs invisible" (`command.rs:12`).
|
||
- `SourceLocation { file, line }` captured from Lua debug info at
|
||
registration, surfaced verbatim by `pmacs.describe.*`.
|
||
- Both live behind `Rc<RefCell<…>>` as Lua app data
|
||
(`SharedCommandRegistry`, `SharedHookRegistry`, `mod.rs:2265-2271`).
|
||
- `HookRegistry::snapshot` exists specifically so the caller can drop
|
||
the registry borrow before running user code that may re-enter.
|
||
|
||
A settings registry that does not look like these two would be the odd
|
||
one out for no reason.
|
||
|
||
### Scoping: what exists, what does not
|
||
|
||
- **Window-local** exists exactly once, ad-hoc:
|
||
`core.active_window_mut().line_numbers` (`mod.rs:11254`).
|
||
- **Buffer-keyed side tables** are established:
|
||
`BufferRemoveCallbacks` is a `HashMap<BufferId, Vec<…>>`
|
||
(`mod.rs:133-176`); `KeymapStack` keys per-buffer maps the same way.
|
||
- **`Buffer` has no property bag.** The struct (`buffer.rs:158`) is
|
||
rope, name, revision, views, marks, undo/redo, path, file meta, the
|
||
in-flight-edit flag, and optional CRDT state. There is nowhere to put
|
||
a setting, and it is the type carrying undo and CRDT invariants.
|
||
- **Buffer death has one choke point**, `after_buffer_removed`
|
||
(`mod.rs:1458`), which already drops that buffer's keymaps before
|
||
firing remove callbacks. Three call sites route through it
|
||
(`mod.rs:3033`, `3123`, `5014`).
|
||
- **`BufferId`s are never reused** — allocated from a global counter and
|
||
documented unique (`buffer_registry.rs:82-84`). Any leaked per-buffer
|
||
state is permanent-but-bounded and can never alias onto a future
|
||
buffer.
|
||
- **The mode system is unwired.** Of 33 `.resolve(` sites in `src/`,
|
||
every `KeymapStack::resolve` in the editor passes `&[]` for
|
||
`active_modes`; the only non-empty callers are two unit tests inside
|
||
`keymap_stack.rs`. Mode-scoped anything is unavailable today.
|
||
- **Project detection exists**: `pmacs.project.detect(path)` →
|
||
`{root, kind}` (`mod.rs:10360`).
|
||
- **Language resolution exists**: `buffer_language` (`lsp.lua:471`),
|
||
exported as `pmacs.lsp.buffer_language` (`lsp.lua:494`), added by
|
||
auto-pairing (#110).
|
||
|
||
### Init ordering, and the freeze point (corrected in revision 2)
|
||
|
||
Revision 1 said `init.lua` runs "from `load_user_config` on the real
|
||
entry points only." **That was wrong** — it described `install_state_dirs`
|
||
(`editor.rs:551`), the Arc 3 pattern, not user config. The truth:
|
||
|
||
Builtin runtime chunks are evaluated inside `EditorState::new()`
|
||
(`editor.rs:194-408`, the linear bootstrap). At the tail of the *same*
|
||
function, under a single `#[cfg(not(test))]` block (`editor.rs:465-476`):
|
||
|
||
```rust
|
||
#[cfg(not(test))]
|
||
{
|
||
crate::config::load_user_config(&mut lua_host);
|
||
lua_host.set_init_complete();
|
||
}
|
||
```
|
||
|
||
Three consequences the design depends on:
|
||
|
||
1. **Builtins define, the user sets, the flag freezes** — one ordered
|
||
sequence, no new machinery needed.
|
||
2. Because it lives in `new()` itself, the freeze covers the daemon
|
||
entry (`daemon.rs:468`) and the local one uniformly. This retires
|
||
revision 1's bet 6: the `StartupOnly` freeze point exists and is
|
||
`InitCompleteFlag` (`mod.rs:2270`), consumed by `require_init_phase`
|
||
(`mod.rs:663`).
|
||
3. **In `--lib` test builds neither line runs.** User config is never
|
||
loaded and the flag never flips. Any acceptance test asserting
|
||
post-freeze behavior must flip it explicitly, the way
|
||
`mod.rs:14655-14690` already does; otherwise it passes vacuously.
|
||
|
||
### Both Lua backends ship
|
||
|
||
`Cargo.toml:75-77` — `default = ["luajit"]`, with `lua54` a supported,
|
||
mutually exclusive fallback (audit F-002 pins the
|
||
`--no-default-features --features lua54` build). LuaJIT is Lua 5.1
|
||
semantics and has no native integer subtype; lua54 does. Any numeric
|
||
validation must therefore behave identically under both feature
|
||
selections and cannot rely on `math.type`. The same caution kills two
|
||
other tempting designs: GC-timed listener lifetime (Q#CR6) and `#` on a
|
||
holey array (Q#CR3).
|
||
|
||
### No GC-cleanup precedent exists
|
||
|
||
`mod.rs` contains **zero** `MetaMethod::Gc` implementations. The only
|
||
`dispose` binding is the explicit one at `mod.rs:1867` (the compile-mode
|
||
overlay handle). Resource lifetime in this codebase is
|
||
explicit-dispose, never finalizer-driven.
|
||
|
||
### Tab width — the motivating example is worse than advertised
|
||
|
||
The backlog calls this "the five hardcoded tab-width sites." The scout
|
||
found five sites across **two crates with two different values**:
|
||
|
||
- `const TAB_WIDTH: u32 = 8` in `src/text_view.rs:35`,
|
||
`src/highlight.rs:321`, `src/diag.rs:393`, `src/completion.rs:594` —
|
||
four independent copies of the same constant and the same
|
||
`col += TAB_WIDTH - (col % TAB_WIDTH)` expansion.
|
||
- `pmacs-gpu/src/main.rs:6728-6733` — `advance_minimap_col` expands a
|
||
tab to **4**, not 8.
|
||
|
||
And the GPU's main text path expands nothing at all: buffer text reaches
|
||
the frontend as raw bytes via `BufferSnapshot`/`CrdtOp`, so a literal
|
||
`\t` is handed to glyphon and shaped by the font. (The only two `'\t'`
|
||
sites in `pmacs-gpu/src/main.rs` are both in the minimap.) Tab rendering
|
||
is already inconsistent between the two frontends *before* any setting
|
||
exists. This is a rendering-parity bug wearing a config-shaped hat, and
|
||
it drives Q#CR13.
|
||
|
||
---
|
||
|
||
## Non-goals
|
||
|
||
No settings GUI. No second config-file format. No persistence outside
|
||
`init.lua`. No per-project trust or loading policy. No automatic
|
||
migration of every existing setter. No protocol messages and no
|
||
frontend-local pixel/font settings. No filesystem watching or hot reload
|
||
of `init.lua`.
|
||
|
||
---
|
||
|
||
## Decisions
|
||
|
||
### Q#CR1 — Scope: substrate, two scopes, three consumers, no wire
|
||
|
||
Stage 1 delivers the registry, the Lua surface, buffer-local scoping,
|
||
discovery, and exactly three first consumers (Q#CR8). It does **not**
|
||
deliver persistence, a `customize` UI, a settings panel, tab width,
|
||
per-language or per-project scopes, or any protocol change. One feature,
|
||
one branch, one PR.
|
||
|
||
The deliverable that justifies the PR on its own: the named backlog item
|
||
"per-buffer auto-pair toggle (config-registry-blocked)" stops being
|
||
blocked.
|
||
|
||
### Q#CR2 — `src/config_registry.rs`: a third registry, Rust-owned
|
||
|
||
New module `src/config_registry.rs` — not `config.rs`, which the
|
||
`init.lua` loader owns. Shape mirrors `hook.rs`:
|
||
|
||
```rust
|
||
pub struct ConfigDefinition {
|
||
pub name: String,
|
||
pub description: String, // R42, mandatory
|
||
pub kind: ConfigKind,
|
||
pub default: ConfigValue,
|
||
pub mutability: ConfigMutability, // Live | StartupOnly
|
||
pub source: SourceLocation,
|
||
}
|
||
|
||
pub struct ConfigListener {
|
||
id: u64, // generation-safe, never reused
|
||
name: String,
|
||
body: Function,
|
||
source: SourceLocation,
|
||
}
|
||
|
||
pub struct ConfigRegistry {
|
||
by_name: HashMap<String, ConfigDefinition>,
|
||
order: Vec<String>,
|
||
global: HashMap<String, ConfigValue>, // overrides only
|
||
locals: HashMap<BufferId, HashMap<String, ConfigValue>>,
|
||
listeners: Vec<ConfigListener>, // registration order
|
||
next_listener_id: u64,
|
||
frozen: bool, // Q#CR10
|
||
definition_epoch: u64,
|
||
value_epoch: u64,
|
||
}
|
||
```
|
||
|
||
**Override storage versus epoch advancement are two different questions
|
||
(F1).** Revision 1 conflated them and broke the flagship feature. The
|
||
rule:
|
||
|
||
- **An override is always stored**, even when it equals the value it
|
||
shadows. `set` and `set_local` unconditionally record an entry, which
|
||
is what makes `is_set` meaningful (Q#CR4) and what makes a
|
||
buffer-local *pin* actually pin.
|
||
- **`value_epoch` advances, and listeners fire, only when an effective
|
||
value changes.** Storing an override equal to the current effective
|
||
value is observationally silent but not structurally absent.
|
||
|
||
The failure this prevents: with `editing.auto-pair` globally `true`, a
|
||
user calls `set_local(buf, "editing.auto-pair", true)` to pin that
|
||
buffer, then later `set("editing.auto-pair", false)` globally. Under
|
||
"true no-op" the local was never stored and the pinned buffer flips —
|
||
the pin silently never existed. Acceptance 11 pins this, bite-verified.
|
||
|
||
`definition_epoch` advances when a key is defined; `value_epoch` gives
|
||
future render-path consumers a single `u64` to gate on, mirroring the
|
||
split syntax/face epochs from #120 (Q#TH6).
|
||
|
||
`ConfigError` carries `EmptyName`, `MissingDescription` (R42),
|
||
`DuplicateName`, `NotFound`, `UnknownField` (R50, listing supported
|
||
keys), `TypeMismatch { name, expected, got }`, `OutOfRange`,
|
||
`NotAChoice { name, got, choices }`, `StartupOnlyLocal` (Q#CR10), and
|
||
`StartupOnlyAfterFreeze`. Behind
|
||
`SharedConfigRegistry = Rc<RefCell<ConfigRegistry>>` as Lua app data,
|
||
set beside the other five at `mod.rs:2265-2271`.
|
||
|
||
**Rust-owned, not a Lua table**, for two reasons. Rust consumers must
|
||
read a setting without borrowing Lua (the tab-width sites in Q#CR13 are
|
||
the eventual proof). And duplicate/typo/type rejection must be enforced
|
||
somewhere a user's `init.lua` cannot bypass — the argument
|
||
`command.rs:12` already makes.
|
||
|
||
**Bindings go in a new `src/lua_bindings/config.rs`, not in `mod.rs`.**
|
||
The withdrawn draft proposed an "owned section of
|
||
`src/lua_bindings/mod.rs`"; that file is 15,594 lines, the paused F-016
|
||
split lives there, and the concurrent vterm lane names it as its own
|
||
overlap file. A submodule reduces the shared-file footprint to one `mod`
|
||
line plus one `install_config(...)` call and removes almost all of the
|
||
inter-lane conflict surface.
|
||
|
||
### Q#CR3 — Closed value vocabulary, owned data, strict specs
|
||
|
||
```
|
||
boolean | integer | number | string | enum
|
||
```
|
||
|
||
`string-list` is **dropped** from stage 1 (F6). No stage-1 adopter wants
|
||
it, and "copied densely from a 1-based array" does not say what happens
|
||
to a table with holes — `#` on a holey table returns an arbitrary
|
||
border, with no guarantee LuaJIT and lua54 choose the same one.
|
||
Admitting it means either explicit hole-rejection logic or a
|
||
cross-backend nondeterminism vector that acceptance 6 would have to
|
||
chase. It returns with its first real adopter and an explicit hole rule.
|
||
|
||
`ConfigValue` is owned Rust data. Lua tables, functions and userdata are
|
||
never stored. Metadata returned to Lua (`describe`, `list`) is a fresh
|
||
table each call, never a handle onto registry state. Definition specs
|
||
are strict raw tables: unknown fields, missing fields,
|
||
metatable-provided values, wrong types, non-finite bounds, inverted
|
||
ranges, duplicate enum choices, and a default that violates its own
|
||
contract all reject **before** any mutation. Defaults pass the same
|
||
validator as user values.
|
||
|
||
Numbers must be finite; integers must be exact — checked by value, not
|
||
by `math.type`, per the two-backend ground truth. `integer` and `number`
|
||
carry optional `min`/`max`.
|
||
|
||
Tables as *values* stay out. Table-valued configuration already has a
|
||
working home — `pmacs.lsp.config`, `pmacs.pair.sets`,
|
||
`pmacs.comment.strings`, the write-through proxies — and admitting them
|
||
means answering deep-equality, merge-vs-replace, per-key validation and
|
||
per-key notification. That is a second arc.
|
||
|
||
### Q#CR4 — Two scopes: global and buffer-local. Language and project are patterns, not scopes
|
||
|
||
This is the framing's load-bearing decision and the main departure from
|
||
the withdrawn draft, which was global-only. Global-only would not
|
||
unblock a single one of the five backlog items the registry exists to
|
||
unblock — four of the five are inherently per-buffer or per-language.
|
||
|
||
```lua
|
||
pmacs.config.define { name = …, description = …, type = …, default = … }
|
||
pmacs.config.set(name, value) -- global override
|
||
pmacs.config.set_local(buf, name, value) -- buffer-local override
|
||
pmacs.config.get(name [, buf]) -- see resolution below
|
||
pmacs.config.reset(name [, buf]) -- drop exactly one layer
|
||
pmacs.config.is_set(name [, buf]) -- override present, not value ≠ default
|
||
```
|
||
|
||
**Resolution, pinned (F9).** `get(name, buf)` resolves
|
||
buffer-local → global → default. `get(name)` with **no buffer argument
|
||
resolves the global chain only** (global override → default) and never
|
||
consults an implicitly-active buffer. The signature is the contract;
|
||
there is no hidden ambient buffer. A consumer that wants buffer-aware
|
||
behavior must pass the buffer, and the acceptance list pins that
|
||
forgetting it yields the global value rather than a surprise.
|
||
|
||
`reset(name)` drops the global override; `reset(name, buf)` drops only
|
||
that buffer's local layer and re-exposes the global. `is_set` reports
|
||
override *presence* at the queried layer, which is well-defined
|
||
precisely because Q#CR2 always stores overrides.
|
||
|
||
Per-language and per-project behavior is achieved the way Emacs achieves
|
||
it: a callback on `buffer.after-load` reads
|
||
`pmacs.lsp.buffer_language(buf)` or `pmacs.project.detect(buf:path())`
|
||
and calls `set_local`. The registry never learns what a language or a
|
||
project is.
|
||
|
||
The claim, stated so it can be falsified: **all five backlog-blocked
|
||
features are expressible as a hook that sets buffer-locals.**
|
||
Language-aware indent, per-language comment padding and the per-buffer
|
||
auto-pair toggle are buffer-local by nature. Per-project compile
|
||
commands are a `set_local` keyed on the detected root. Tab width is
|
||
buffer-local (Q#CR13 defers it for an unrelated reason).
|
||
|
||
Mode-scoped settings are not offered because they cannot work: every
|
||
editor `KeymapStack::resolve` passes `&[]`. Offering a mode scope on an
|
||
unwired mode system would ship a knob that silently never fires.
|
||
|
||
### Q#CR5 — Buffer-local storage: registry-owned, purged at the existing choke point
|
||
|
||
`locals: HashMap<BufferId, HashMap<String, ConfigValue>>` inside the
|
||
registry, following `BufferRemoveCallbacks` (`mod.rs:133`) rather than
|
||
adding a field to `Buffer` — the buffer struct carries undo and CRDT
|
||
invariants and has no property bag, and config does not belong in it.
|
||
|
||
Purge rides `after_buffer_removed` (`mod.rs:1458`), one line beside the
|
||
keymap purge already there. No new hook is defined for this, and the
|
||
purge does **not** fire listeners (Q#CR6): the buffer is gone, so there
|
||
is no effective value for anyone to observe.
|
||
|
||
Honest limitation, corrected in revision 2 (F8): `BufferRegistry::remove`
|
||
can be called directly without going through `remove_buffer_and_fire`
|
||
(`editor.rs:5527` does so in a test). Such a path leaks that buffer's
|
||
locals **permanently** — not "until the `BufferId` is reused", because
|
||
`BufferId`s come from a global counter and are never reused
|
||
(`buffer_registry.rs:82-84`). The leak is therefore bounded and can
|
||
never alias onto a future buffer, which makes it a memory footnote
|
||
rather than a correctness hazard. Stage 1 pins the contract with
|
||
acceptance 13 rather than restructuring `BufferRegistry`.
|
||
|
||
### Q#CR6 — Change delivery: post-commit listeners, borrow released
|
||
|
||
Carried forward from the withdrawn draft, whose design here is better
|
||
than a plain hook and matches the compile-mode `handle:dispose()`
|
||
precedent (`mod.rs:1867`). The registry never runs arbitrary Lua while
|
||
mutably borrowed.
|
||
|
||
```lua
|
||
local handle = pmacs.config.on_change('editing.auto-pair', function(new, old, buf)
|
||
-- receives already-validated owned values
|
||
end)
|
||
handle:dispose() -- idempotent, generation-safe
|
||
```
|
||
|
||
Set/reset flow: resolve and validate the candidate with no mutation →
|
||
**store the override unconditionally** (Q#CR2) → advance `value_epoch`
|
||
iff the effective value changed → **release the registry borrow** →
|
||
if the effective value changed, invoke listeners in registration order
|
||
with copied values → log a failing listener to the normal Lua error sink
|
||
and continue the rest.
|
||
|
||
**Dispatch semantics, pinned (F2):**
|
||
|
||
- **(a)** A global `set` that changes the global effective value fires
|
||
once with `buf = nil`.
|
||
- **(b)** It does **not** additionally fire per-buffer. A buffer holding
|
||
its own override is shadowed — its effective value did not change — so
|
||
a listener that cares about a specific buffer must re-resolve with
|
||
`get(name, buf)`. The notification says "the global changed", not
|
||
"every buffer changed".
|
||
- **(c)** The `after_buffer_removed` purge (Q#CR5) fires nothing.
|
||
- **(d)** `on_change` on an undefined name raises `NotFound`, matching
|
||
the define-before-set posture of Q#CR10.
|
||
|
||
None of the three stage-1 adopters consumes `on_change` — `pair.lua`
|
||
reads at insert time, `editops.lua` at save, `autosave.lua` per tick —
|
||
so the acceptance tests are the only exercise these semantics get. That
|
||
is precisely why the contract is written out here rather than left to
|
||
the first consumer to discover.
|
||
|
||
**Listeners persist until explicitly disposed (F3).** Revision 1 said a
|
||
garbage-collected handle stops firing; that is dropped. There is no
|
||
`MetaMethod::Gc` anywhere in `mod.rs`, the compile-mode precedent is
|
||
explicit-dispose only, and GC timing differs between LuaJIT and lua54 —
|
||
importing finalizer nondeterminism would cut directly against the
|
||
cross-backend exactness this framing demands elsewhere. A user who
|
||
registers `on_change` and drops the handle keeps the listener; that is
|
||
the same bargain `pmacs.hook.add` already makes.
|
||
|
||
A listener error does not roll the value back: earlier listeners may
|
||
already have applied side effects, and rollback would create two sources
|
||
of truth. Re-entrant `set`/`reset` is permitted after borrow release and
|
||
produces a later notification epoch; a per-dispatch recursion bound
|
||
stops an accidental listener cycle from hanging the editor. Listener ids
|
||
are never reused, so a stale handle can never dispose a newer listener.
|
||
|
||
The borrow-release step is the one to bite-verify —
|
||
`HookRegistry::snapshot` exists for exactly this reason, and the
|
||
statusline arc's three-phase borrow-released transaction is the recent
|
||
precedent for getting it wrong being expensive.
|
||
|
||
### Q#CR7 — No wire surface; protocol stays at v18
|
||
|
||
Every stage-1 setting is read daemon-side, in Lua. Nothing new goes on
|
||
the wire and `SUPPORTED` is untouched.
|
||
|
||
Settings whose consumer lives in a frontend already have their own
|
||
authoritative facts channel — `ThemeFacts` (v16), `FontFacts` (v17),
|
||
`StatuslineSegments` (v18), `LineNumbers` (v13/v14). That
|
||
one-channel-per-concern shape is deliberate, with an
|
||
authoritative-per-attachment contract and a snapshot/baseline reset
|
||
contract behind it (#120 rounds 2-5); a generic "config facts" channel
|
||
would have to re-derive all of that and would fit worse than what those
|
||
four already do. The registry is not a transport.
|
||
|
||
### Q#CR8 — Three first consumers, chosen to prove three shapes
|
||
|
||
| Setting | Type | Proves | Status |
|
||
| --- | --- | --- | --- |
|
||
| `editing.auto-pair` | boolean | buffer-local resolution | **new** — unblocks the named backlog item |
|
||
| `editing.trim-on-save` | boolean | migration behind a stable API | migrates `editops.lua:844` |
|
||
| `autosave.interval-ms` | integer, min 1000 | validation + live re-read each tick | migrates `autosave.lua:42` |
|
||
|
||
All three are consumed entirely in Lua, on the daemon, which is what
|
||
makes Q#CR7 true. The withdrawn draft deferred adopter selection to
|
||
implementation time; naming them now is what lets the acceptance list
|
||
below be written before any code exists.
|
||
|
||
**No existing surface is removed**, and the wrappers keep their legacy
|
||
coercion (F4). This is the subtle part of the migration. Today
|
||
`pmacs.autosave.interval_ms(1500.7)` succeeds and floors to `1500`
|
||
(`autosave.lua:48`), and `pmacs.editops.trim_on_save("yes")` sets true
|
||
(`on ~= false`, `editops.lua:846`). A *thin* wrapper over a strict
|
||
`config.set` would reject both, because Q#CR3's `integer` demands
|
||
exactness and `boolean` demands a real boolean. So the wrappers coerce
|
||
first — `math.floor` / `~= false` — and then call `set` with an
|
||
already-conforming value. The registry stays strict; the legacy API
|
||
stays lenient; acceptance 27-28 pin both directions on inputs no current
|
||
test covers.
|
||
|
||
One honest divergence from "the wrapper's shape stays exactly as it
|
||
was", found in review round 1: `pmacs.autosave.interval_ms(1e30)`
|
||
previously stored the float, and now raises `NonIntegral` because the
|
||
floored value exceeds `i64` range. The old behavior stored a nonsense
|
||
interval; the new one refuses it. An improvement, but a behavior change
|
||
at the extreme rather than a pure no-op migration, so it is recorded
|
||
here rather than claimed away.
|
||
|
||
Richer feature-specific APIs are explicitly not deleted:
|
||
`pmacs.gpu.set_font` remains the font preference API until a separately
|
||
framed migration decides how a daemon-global preference and
|
||
frontend-local resolution interact.
|
||
|
||
`pair.lua` reads `editing.auto-pair` in its insertion predicate. It
|
||
loads before `lsp.lua` by an existing ordering contract (Q#AP7,
|
||
`editor.rs:319` and `:325`), so the config surface must be installed
|
||
before `pair.lua` (Q#CR14).
|
||
|
||
### Q#CR9 — Names are dotted and kebab-cased
|
||
|
||
`editing.auto-pair`, `autosave.interval-ms`, and — when it arrives —
|
||
`editor.tab-width`. Lowercase ASCII segments, total length bounded at
|
||
128 bytes.
|
||
|
||
Segment grammar, tightened in revision 2: `[a-z][a-z0-9]*(-[a-z0-9]+)*`.
|
||
Revision 1's `[a-z][a-z0-9_-]*` admitted `auto-` and `a--b`; this form
|
||
forbids a trailing hyphen and a doubled hyphen while accepting every
|
||
name we actually want.
|
||
|
||
The withdrawn draft used snake_case (`editor.tab_width`). Kebab matches
|
||
the two registries that already exist — `buffer.before-save`,
|
||
`buffer.after-switch`, `buffer.self-insert`, `buffer.kill-this` — and
|
||
setting *names* are strings in the registry vocabulary, not Lua
|
||
identifiers. Lua field names stay snake_case as they are today.
|
||
|
||
### Q#CR10 — Define before set; `Live` vs `StartupOnly`
|
||
|
||
`pmacs.config.set` on an undefined name raises `NotFound`, exactly as
|
||
`pmacs.hook.add` does. Silent acceptance of an unknown name is how typos
|
||
become permanent mysteries.
|
||
|
||
Definitions are immutable after first registration, except that a
|
||
byte-for-byte identical redefinition succeeds (idempotent reload); a
|
||
conflicting redefinition fails and leaves the original exactly as it
|
||
was.
|
||
|
||
`mutability` is `Live` or `StartupOnly`. `StartupOnly` keys accept
|
||
writes while user config is loading and freeze when `set_init_complete`
|
||
runs at the tail of `EditorState::new()` (`editor.rs:465-476`); a later
|
||
write returns an error naming the key and the policy. This generalizes
|
||
the posture `require_init_phase` (`mod.rs:663`) already hard-codes for
|
||
`pmacs.attach`, and gives it a declarative home.
|
||
|
||
**`StartupOnly` and `set_local` are mutually exclusive (F5).**
|
||
Buffer-locals are set at runtime, from `buffer.after-load` hooks that
|
||
fire long after the freeze — so a `StartupOnly` key could never carry a
|
||
buffer-local override, and a `set_local` against one would be dead code
|
||
that looks live.
|
||
|
||
**Corrected in revision 3.** Revision 2 said "`define` rejects the
|
||
combination outright", which is not implementable: `define` cannot know
|
||
about a future `set_local`, and `StartupOnly` is perfectly legal on its
|
||
own. The rejection lives where the combination actually manifests —
|
||
**`set_local` returns `StartupOnlyLocal` when the named definition is
|
||
`StartupOnly`**, unconditionally and independent of freeze state, so the
|
||
error is the same before and after startup rather than changing shape
|
||
mid-session.
|
||
|
||
`reset(name, None)` after the freeze is also refused for a `StartupOnly`
|
||
key: dropping a frozen override would let the default silently reassert
|
||
itself post-freeze, which is the same hazard `set` is blocked for.
|
||
Buffer-local `reset` needs no such check, since such a key can never
|
||
hold a local override in the first place.
|
||
|
||
The ordering that makes define-before-set safe is the corrected sequence
|
||
in ground truth: builtins define during `EditorState::new()`, user
|
||
config runs, then the freeze — all inside the same function. Packages
|
||
loading after init cannot be pre-configured from `init.lua` — the
|
||
existing v0.1 posture, not a new limitation. Staging pending sets for
|
||
later-defined names is deferred.
|
||
|
||
### Q#CR11 — Discovery: `describe` + `list` + `M-x describe-setting`
|
||
|
||
`pmacs.config.describe(name [, buf])` returns a fresh table with `name`,
|
||
`description`, `type`, `default`, `choices`, bounds, `mutability`,
|
||
`value`, `global`, `buffer_local`, and `source`.
|
||
|
||
**`buffer_local`, not `local` (F7)** — `local` is a Lua keyword, so
|
||
`info.local` is a syntax error and every consumer would be forced to
|
||
write `info["local"]`. The field is present only when a buffer argument
|
||
is given and that buffer holds an override.
|
||
|
||
`pmacs.config.list()` returns fresh metadata tables in **definition
|
||
order**, matching `names()` and the command/hook registries' "stable
|
||
listing" rule. Neither `describe` nor `list` ever exposes an internal
|
||
table or a listener function.
|
||
|
||
Revision 3 clarification: revision 2 said "deterministic by key", which
|
||
implementation read as possibly meaning sorted-by-name. The requirement
|
||
is *determinism* — never `HashMap` iteration order — and definition
|
||
order satisfies it while staying consistent with the two sibling
|
||
registries. A UI that wants alphabetical sorts at the presentation
|
||
layer; the substrate does not decide that. Bounds are exposed as flat
|
||
`min` / `max` fields, not a nested `bounds` table.
|
||
|
||
`M-x describe-setting` renders through `src/help.rs`, following
|
||
`render_hook` / `format_hook_text` (`help.rs:160`, `:170`) so the
|
||
`*help*` buffer, its link spans and its view-rebuild path work
|
||
unchanged. The withdrawn draft had `describe`/`list` as Lua-only; wiring
|
||
the `*help*` surface is what makes the registry discoverable to a user
|
||
rather than to a script.
|
||
|
||
A `list-settings` listview panel is deferred — the machinery exists from
|
||
Arc 1b, but it is UI scope on a substrate PR.
|
||
|
||
### Q#CR12 — No persistence in stage 1
|
||
|
||
Emacs's `custom-file` problem — the init file says one thing, the
|
||
persisted state file says another, and the user cannot tell which won —
|
||
is a genuine design hazard deserving its own framing round. The
|
||
`$XDG_STATE_HOME/pmacs` machinery from Arc 3 exists
|
||
(`install_state_dirs`, `editor.rs:551`), so this is a question about
|
||
what we want, not about plumbing.
|
||
|
||
### Q#CR13 — Tab width is stage 2, and the reason is not config
|
||
|
||
Tab width is the backlog's headline example, and the withdrawn draft
|
||
used `editor.tab_width` as its running example and candidate adopter. It
|
||
is deliberately out of stage 1 here.
|
||
|
||
Per the ground truth: the daemon has four `TAB_WIDTH = 8` constants, the
|
||
GPU minimap expands tabs to 4, and the GPU's main text path does not
|
||
expand tabs at all. So `editor.tab-width` cannot be honored on the GPU
|
||
by defining a setting. It needs frontend tab expansion, a decision about
|
||
whether the value crosses the wire or the frontend reads its own, and a
|
||
rendering-parity fix that stands on its own merits. Note that the
|
||
withdrawn draft's own non-goals excluded protocol messages and
|
||
frontend-local settings — which its headline example required. Stage 1
|
||
resolves that contradiction by deferring the example, not the non-goal.
|
||
|
||
Stage 2: unify the four daemon constants into a resolved value threaded
|
||
through the display-column functions as a parameter — they are pure
|
||
functions today and should stay pure — then answer the GPU question.
|
||
|
||
### Q#CR14 — No runtime chunk; each module defines its own keys
|
||
|
||
**Revised in revision 3.** Revision 2 put a "friendly Lua surface" in
|
||
`builtin/runtime/config.lua`, loaded after `fs.lua`. Implementation
|
||
established there is nothing for that file to hold, so **it does not
|
||
exist**.
|
||
|
||
Two facts kill it. First, `pmacs.config` is installed entirely from
|
||
Rust by `attach_editor`, which runs before *any* `builtin/runtime/*.lua`
|
||
chunk is evaluated in `EditorState::new()` — so the bindings are already
|
||
the friendly surface, with no raw underscore layer needing a Lua wrapper
|
||
the way `pmacs._async` needs `async.lua`. `pmacs.command` and
|
||
`pmacs.hook` are the precedent: both are pure-Rust surfaces with no
|
||
runtime chunk. Second, the one helper such a file might plausibly hold —
|
||
a shared `define`-wrapping convenience — would actively break acceptance
|
||
9, because `SourceLocation` is captured from Lua debug info at the
|
||
`define` call site, so routing every builtin define through a helper in
|
||
`config.lua` would make every builtin setting report `config.lua` as its
|
||
source instead of its owning module.
|
||
|
||
The module documentation that revision 2 assigned to that chunk lives in
|
||
the `src/lua_bindings/config.rs` module header instead.
|
||
|
||
Builtin `define` calls live with their owning modules (F10), not
|
||
centralized. `pair.lua` defines `editing.auto-pair`, `editops.lua`
|
||
defines `editing.trim-on-save`, `autosave.lua` defines
|
||
`autosave.interval-ms`. `pair.lua`'s load-before-`lsp.lua` contract
|
||
(`editor.rs:319`, `:325`) is untouched, and because the bindings install
|
||
ahead of every chunk, no load-order constraint is added by this arc at
|
||
all.
|
||
|
||
Consequence worth noting for the concurrent vterm lane: this arc now
|
||
touches `src/editor.rs` **zero** times.
|
||
|
||
**Builtin `define` calls live with their owning modules (F10)**, not
|
||
centralized in `config.lua`. Revision 1 centralized them, which would
|
||
have pointed every builtin setting's `SourceLocation` at `config.lua` —
|
||
weakening exactly what acceptance 9 celebrates — and coupled
|
||
`config.lua` to autosave's floor, pair's default and editops' semantics.
|
||
The hook precedent is owner-defines: `builtin/hooks/default.lua` defines
|
||
hooks, but each module owns its own behavior. So `pair.lua` defines
|
||
`editing.auto-pair`, `editops.lua` defines `editing.trim-on-save`, and
|
||
`autosave.lua` defines `autosave.interval-ms`. Ordering still works —
|
||
every builtin chunk precedes `init.lua` regardless.
|
||
|
||
The Rust `install_config` call sits with the other installs in
|
||
`attach_editor`. Total footprint in the two files the vterm lane also
|
||
touches: one install call plus one chunk load in `editor.rs`, one `mod`
|
||
line plus one call in `lua_bindings/mod.rs`.
|
||
|
||
### Q#CR15 — Threading and hot paths
|
||
|
||
Main-thread `Rc<RefCell<_>>`, matching the syntax, statusline, command
|
||
and hook registries. Reads return borrowed or copied data internally and
|
||
allocate only when crossing into Lua metadata. Render hot paths cache a
|
||
typed value or gate on `value_epoch`; they must never build a Lua table
|
||
or do a string lookup per cell or per frame.
|
||
|
||
No worker-thread mutation. Workers receive copied settings in job specs,
|
||
so a live change affects future jobs only, unless the owning feature
|
||
explicitly cancels and restarts work.
|
||
|
||
---
|
||
|
||
## Bets
|
||
|
||
1. **Global + buffer-local is sufficient.** Falsified if any of the five
|
||
backlog-blocked features cannot be expressed as a hook that calls
|
||
`set_local`. The most likely falsifier is per-project compile
|
||
commands, where the natural key is a project root — if a
|
||
project-scoped value must survive with no buffer open, this fails and
|
||
a third scope is needed.
|
||
2. **No protocol change is needed.** Falsified if any stage-1 consumer
|
||
turns out to have a frontend-side reader. Checked against all three:
|
||
`pair.lua`, `editops.lua`, `autosave.lua` all run daemon-side.
|
||
3. **Scalars are sufficient for stage 1.** Falsified if a first consumer
|
||
wants a table- or list-valued setting. The three chosen adopters are
|
||
two booleans and an integer, so this is near-certain for stage 1 and
|
||
says nothing about stage 2.
|
||
4. **`after_buffer_removed` catches every buffer death that matters.**
|
||
Falsified by a production path that removes a buffer from the
|
||
registry without it. The known exception is test-only today
|
||
(`editor.rs:5527`), and per F8 its blast radius is a bounded,
|
||
non-aliasing leak.
|
||
5. **Migrating two settings behind unchanged public functions is
|
||
invisible to users.** Falsified if any observable behavior of
|
||
`trim_on_save` / `interval_ms` changes — including the coercion
|
||
behavior on non-conforming inputs, which is why F4's pins exist.
|
||
|
||
Revision 1's bet 6 (a real `StartupOnly` freeze point) is **confirmed**
|
||
and retired into ground truth: `set_init_complete` at
|
||
`editor.rs:465-476`, inside `EditorState::new()`, covering both entry
|
||
points.
|
||
|
||
---
|
||
|
||
## Deferred (named)
|
||
|
||
- **Persistence** and the `custom-file` split-brain question (Q#CR12).
|
||
- **Tab width and the GPU tab-expansion parity fix** — stage 2
|
||
(Q#CR13), including the GPU minimap's divergent width of 4.
|
||
- **`string-list`** (F6) — returns with its first adopter and an
|
||
explicit hole-rejection rule.
|
||
- **Per-language and per-project first-class scopes**, if bet 1 falls.
|
||
- **Mode scope** — blocked on wiring the mode system (every editor
|
||
`resolve` passes `&[]`); a cross-cutting backlog item in its own right.
|
||
- **Window-local scope** — `line_numbers` is the existing precedent and
|
||
the first migration if a second window-local setting appears.
|
||
- **Table-valued settings** (Q#CR3), and with them any migration of
|
||
`pmacs.lsp.config`, `pmacs.pair.sets`, `pmacs.comment.strings` or the
|
||
`pmacs.parse.*` write-through proxies.
|
||
- **Migrating the remaining scalar surfaces** — `async_config` (×2),
|
||
`killring.max`, `autosave.enable`, `recentf.enable`,
|
||
`saveplace.enable`, `session.desktop_mode`.
|
||
- **Migrating `pmacs.gpu.set_font`** — needs its own framing for the
|
||
daemon-preference / frontend-resolution split (Q#CR8).
|
||
- **Deprecating the getter-when-nil functions** once migration
|
||
completes; stage 1 keeps every one of them, coercion included.
|
||
- **Pending-set staging** for names defined after `init.lua` runs
|
||
(Q#CR10), which would also give packages a configuration story.
|
||
- **`M-x list-settings`** as a listview panel (Q#CR11).
|
||
- **A `customize`-style editing UI**, and a `:set`-style minibuffer
|
||
command.
|
||
- **A `scope = "global"` define flag** (review round 1, finding 2).
|
||
`set_local` currently succeeds for any `Live` setting, including ones
|
||
whose consumer only ever reads the global chain. `editing.trim-on-save`
|
||
was fixed by making its consumer buffer-aware — the save hook now
|
||
resolves against the buffer being saved — but a per-buffer
|
||
`autosave.interval-ms` is *semantically* meaningless (there is one
|
||
sweep timer, not one per buffer) and the API still accepts it, stores
|
||
it, and reports it from `describe`. That is a stored value nothing
|
||
reads, the same shape F1 exists to prevent. The fix is a define-time
|
||
scope declaration letting the registry refuse `set_local` outright,
|
||
exactly as `StartupOnlyLocal` already refuses another meaningless
|
||
combination. Deferred rather than taken in review round 1 because it
|
||
adds public API surface after review.
|
||
- **Field-naming in bound-parse errors** — a bad `min` reports the
|
||
config name and the expected type but not *which* of `min`/`max`
|
||
offended. Cheap, but wants its own error variant rather than a
|
||
synthesized pseudo-name.
|
||
- **`reset(name, buf)` symmetry for `StartupOnly`** — `set_local` is
|
||
refused with `StartupOnlyLocal` but the buffer-local `reset` is
|
||
allowed. Unreachable-harmless today (no such local can exist), so a
|
||
rejection would be symmetry rather than a fix.
|
||
|
||
---
|
||
|
||
## Acceptance
|
||
|
||
Registry semantics (unit, `src/config_registry.rs`):
|
||
|
||
1. Valid definitions round-trip every value kind and produce a fresh
|
||
metadata table.
|
||
2. `define` rejects an empty name, a missing or whitespace-only
|
||
description (R42), a duplicate name, an unknown spec key (R50), a
|
||
metatable-provided field, non-finite bounds, an inverted range,
|
||
duplicate enum choices, and a default violating its own contract —
|
||
each **without** adding a definition or advancing either epoch, and
|
||
each with a message naming the offending field.
|
||
3. `define` rejects the name grammar's edge cases: trailing hyphen
|
||
(`auto-`), doubled hyphen (`a--b`), empty segment, leading digit, and
|
||
over-length (Q#CR9).
|
||
4. An identical redefinition is idempotent; a conflicting redefinition
|
||
leaves the original exact.
|
||
5. Values and definitions are deep-copied from Lua — mutating the
|
||
caller's table afterwards cannot alter registry state.
|
||
6. Integer and number finite/boundary cases are exact under **both**
|
||
`--features luajit` (default) and
|
||
`--no-default-features --features lua54`.
|
||
7. `list` returns definition order (never `HashMap` order);
|
||
`describe`/`list` never expose an internal mutable table or a
|
||
listener function, and each call returns a fresh table.
|
||
8. `names()` / `list()` order is stable across ≥3 defines.
|
||
9. `SourceLocation` is captured from the *defining module's* chunk and
|
||
renders `file:line` — `editing.auto-pair` reports `pair.lua`, not
|
||
`config.lua` (Q#CR14).
|
||
|
||
Scoping, storage and buffer-local lifecycle:
|
||
|
||
10. `get(name, buf)` resolves buffer-local → global → default;
|
||
`reset(name, buf)` drops only the local layer and re-exposes the
|
||
global; `reset(name)` drops only the global.
|
||
11. **An equal-valued override is still stored (F1).** With the global
|
||
value `true`, `set_local(buf, name, true)` then
|
||
`set(name, false)` leaves `get(name, buf)` `true` and
|
||
`get(name)` `false` — **bite-verified**: the test fails against a
|
||
"true no-op" implementation that declines to store it.
|
||
12. A buffer-local set on buffer A does not change the value seen in
|
||
buffer B; `is_set` reports override presence per layer, including
|
||
for an equal-valued override.
|
||
13. A buffer-local value is dropped when the buffer is removed through
|
||
`remove_buffer_and_fire`; removing a buffer directly via
|
||
`BufferRegistry::remove` leaves the entry, and the test asserts that
|
||
documented limitation rather than a fix (Q#CR5, F8).
|
||
14. `get(name)` with no buffer argument returns the global chain result
|
||
even when the active buffer holds a different local override (F9).
|
||
15. `value_epoch` advances only on effective-value change; storing an
|
||
equal-valued override advances neither epoch.
|
||
|
||
Listeners (Q#CR6):
|
||
|
||
16. Callbacks run after borrow release, in registration order, with
|
||
copied `(new, old, buf)` values — **bite-verified**: the test fails
|
||
with the borrow held.
|
||
17. A global `set` fires once with `buf = nil` (a), and does **not**
|
||
fire for a buffer whose own override shadows the change (b).
|
||
18. The `after_buffer_removed` locals purge fires no listener (c).
|
||
19. `on_change` on an undefined name raises `NotFound` (d).
|
||
20. One raising listener is logged once and does not block later
|
||
listeners; the committed value stays authoritative.
|
||
21. A re-entrant `set` from inside a listener creates a later ordered
|
||
epoch without a `RefCell` panic; a recursive cycle hits the bounded
|
||
error rather than hanging.
|
||
22. Dispose is idempotent and id-generation-safe; a stale handle never
|
||
disposes a newer listener. A dropped-but-undisposed handle keeps
|
||
firing (F3) — the inverse of revision 1's claim, pinned so the
|
||
behavior cannot silently regress to GC-dependence.
|
||
|
||
Startup and mutability:
|
||
|
||
23. `StartupOnly` keys accept writes before the freeze and reject after,
|
||
with a message naming the key and the policy. **The test flips
|
||
`InitCompleteFlag` explicitly** (`mod.rs:14655-14690` is the
|
||
pattern) — in `--lib` builds `set_init_complete` never runs, so a
|
||
test that omits this passes vacuously.
|
||
24. `set_local` against a `StartupOnly` definition returns
|
||
`StartupOnlyLocal` both before and after the freeze (F5, corrected
|
||
in revision 3 — `define` cannot police a future call). A
|
||
`StartupOnly` global `reset` after the freeze is refused for the
|
||
same reason `set` is.
|
||
25. A failing `init.lua` preserves prior successful sets and still
|
||
starts the editor; missing `init.lua`, broken `init.lua` and
|
||
`require` package-path behavior are unchanged.
|
||
26. The `pmacs.config` table is populated before the first
|
||
`builtin/runtime/*.lua` chunk evaluates — a builtin module's
|
||
top-level `define` call succeeds — and `pair.lua` still loads before
|
||
`lsp.lua` (the Q#AP7 contract is untouched). Revision 3: this
|
||
replaces "`config.lua` is installed before `pair.lua`", which became
|
||
vacuous when that chunk was removed (Q#CR14).
|
||
|
||
Adopters:
|
||
|
||
27. `pmacs.editops.trim_on_save(true)` and
|
||
`pmacs.config.set("editing.trim-on-save", true)` are interchangeable
|
||
in both directions — each observes the other's writes — and
|
||
`trim_on_save("yes")` still sets true (F4).
|
||
28. `pmacs.autosave.interval_ms(1500.7)` still returns `1500`, and
|
||
`interval_ms(500)` still raises on the floor — now enforced by the
|
||
registry validator behind the wrapper's coercion. **Bite-verified**
|
||
against the removed hand-rolled check (F4).
|
||
29. `editing.auto-pair` false globally suppresses pairing; false
|
||
buffer-locally suppresses it in that buffer only, with pairing still
|
||
active in a second buffer of the same language.
|
||
30. The autosave tick observes a mid-session interval change without a
|
||
restart (the existing live-re-read contract, re-pinned), whether the
|
||
change arrived through the wrapper or through a direct
|
||
`pmacs.config.set`.
|
||
31. No adopter builds a Lua table or performs a string-keyed lookup
|
||
**per cell or per rendered line**, and all three preserve their
|
||
previous default behavior exactly. Revision 3 note: this item and
|
||
item 30 read as contradictory in revision 2 — 30 *requires* the
|
||
autosave tick to re-read every frame while 31 forbade a "per-frame
|
||
lookup". Q#CR15's prohibition targets render hot paths, not a single
|
||
O(1) scalar `get` once per tick, which is exactly what Q#CR8 asks
|
||
autosave to prove. One scalar `get` per tick is explicitly
|
||
conforming.
|
||
|
||
Discovery:
|
||
|
||
32. `pmacs.config.describe` returns every documented field, with
|
||
`buffer_local` absent when no buffer-local override exists and the
|
||
field name usable without bracket syntax (F7).
|
||
33. `M-x describe-setting` renders into `*help*` with the source
|
||
location present.
|
||
|
||
Gates: the full standing suite per `CLAUDE.md`, plus the new acceptance
|
||
suite, plus `--no-default-features --features lua54` for item 6.
|
||
|
||
---
|
||
|
||
## Resolved review questions (round 1)
|
||
|
||
1. **Scope set** — accept global + buffer-local. Bet 1's named falsifier
|
||
(a project value surviving with no buffer open) is real and correctly
|
||
deferred.
|
||
2. **`string-list`** — dropped (F6). Returns with an adopter.
|
||
3. **`Live`/`StartupOnly`** — accepted; freeze point confirmed at
|
||
`editor.rs:465-476`, covering both entry points. Test-build caveat
|
||
(acceptance 23) and the `set_local` exclusion (F5) carried into the
|
||
doc.
|
||
4. **No-rollback listener errors** — accepted; matches the hook
|
||
log-and-continue posture.
|
||
5. **Three adopters, `set_font` untouched** — accepted, with F4's
|
||
coercion pins added.
|
||
6. **Defer tab width** — accepted; the parity mess is verified real and
|
||
the withdrawn draft's contradiction correctly diagnosed.
|
||
7. **Kebab-case** — accepted, with the grammar tightened to
|
||
`[a-z][a-z0-9]*(-[a-z0-9]+)*` to forbid `auto-` and `a--b`.
|
||
|
||
Open for round 2: nothing blocking. F10 (owner-defines) was adopted as
|
||
recommended; if the lead prefers centralized defines for reviewability,
|
||
acceptance 9 is the item that changes.
|
||
|
||
---
|
||
|
||
## Lane coordination (config registry ↔ vterm)
|
||
|
||
Both lanes are active concurrently in separate worktrees. Assignment:
|
||
|
||
| File | Owner | Other lane |
|
||
| --- | --- | --- |
|
||
| `src/editor.rs` runtime-load block | **config** — its chunk must load before consumers | vterm appends its chunk at the tail |
|
||
| `src/lua_bindings/mod.rs` | **neither** — config adds `lua_bindings/config.rs`, vterm adds its own submodule; each takes one `mod` line + one install call | keep both footprints to one line |
|
||
| `src/lib.rs` module exports | one line each | trivial rebase conflict, resolve in favor of both |
|
||
| `src/ansi.rs` | **vterm** | config never touches it |
|
||
| `docs/agent-handoff.md`, `docs/active-work.md` | whichever merges second rewrites its §1 entry post-rebase | do not co-edit |
|
||
|
||
Neither feature imports the other's types. Recommended merge order:
|
||
config registry first (smaller, substrate, heaviest footprint in the
|
||
shared files), then vterm rebases. Per the standing constraint, vterm
|
||
keeps terminal settings hard-wired in its early stages and adopts
|
||
`pmacs.config` only after both contracts land, so neither arc is a
|
||
prerequisite for the other.
|
||
|
||
Implementation branch, after framing approval: `config-registry`, cut
|
||
from then-current canonical `main`, never from a vterm branch.
|