feat(config): typed configuration registry with buffer-local scope
A third registry beside CommandRegistry and HookRegistry, per
docs/config-registry-framing.md. Unblocks the per-buffer auto-pair
toggle, the first of the five backlog items the missing config surface
was gating.
Substrate (src/config_registry.rs):
* ConfigRegistry keyed by name with definition order preserved, R42
mandatory descriptions, R50 typo detection, duplicate rejection,
and SourceLocation provenance -- the command/hook vocabulary.
* Closed scalar kinds: boolean, integer, number, string, enum. Owned
Rust values; Lua tables, functions and userdata are never stored.
Integer exactness is checked by value, never math.type, so the
luajit and lua54 builds agree.
* Two scopes. get(name, buf) resolves buffer-local -> global ->
default; get(name) with no buffer resolves the global chain only
and never consults an ambient buffer. Buffer-locals live in a
registry-owned side table purged at after_buffer_removed, beside
the keymap purge already there.
* An override is ALWAYS stored, even when equal to the value it
shadows; only value_epoch and listener dispatch key on effective
change. Without this a buffer pinned to the current value stores
nothing and a later global set flips it -- the pin silently never
existed. equal_valued_local_override_is_still_stored_and_shields_buffer
fails against the naive reading.
Bindings (src/lua_bindings/config.rs):
* define/get/set/set_local/reset/is_set/describe/list/on_change.
Spec tables are read raw, so neither an unknown key nor a
metatable-provided value can smuggle a field in.
* Listeners commit inside the borrow, snapshot, drop the borrow, and
only then re-enter Lua -- verified by holding the borrow and
watching the test panic with "RefCell already borrowed". A raising
listener is logged without blocking later ones or rolling back, and
a depth bound turns an accidental cycle into a pointed error.
Listeners persist until explicitly disposed; there is no Gc path,
matching the rest of the codebase.
* StartupOnly freezes off the existing InitCompleteFlag at write
time, so this arc adds no editor.rs call at all.
Adopters, each defining its own key so SourceLocation names the owning
module: editing.auto-pair (pair.lua, read per-buffer against the typed
edit's SOURCE buffer), editing.trim-on-save (editops.lua),
autosave.interval-ms (autosave.lua). No public function is removed or
deprecated, and both migration wrappers keep their legacy coercion --
trim_on_save("yes") still enables, interval_ms(1500.7) still floors to
1500 -- coercing before handing the strict registry a conforming value.
M-x describe-setting renders into *help*, modeled on describe-command.
Framing revision 3 records four defects implementation found in the
document itself: acceptance 30 and 31 contradicted each other; the
planned builtin/runtime/config.lua had nothing to hold and would have
broken the source-location contract had it held the one helper it might
have; F5 asked define to police a call it cannot see, moved to
set_local; and list() ordering was underspecified.
No protocol change; SUPPORTED stays [6..18]. No wire surface. Zero
changes to src/editor.rs.
Gates: fmt, clippy -D warnings, --lib (1683), --lib --features crdt
(1857), the new config_registry_acceptance (13) plus auto_pair (45),
editops (72), autosave (29) and m9_6 (25), m4 --skip basedpyright
(114), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), the lua54 backend build,
and the full workspace sweep (2795 tests, exit 0). git diff --check
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3bde76126a
commit
6844262495
|
|
@ -1169,3 +1169,70 @@ cmd { name = "editor.describe-command",
|
|||
end,
|
||||
}
|
||||
end }
|
||||
|
||||
-- describe-setting (config registry, acceptance 33) -------------------------
|
||||
--
|
||||
-- The configuration registry's discovery surface. `pmacs.config.describe`
|
||||
-- returns the metadata table at the Rust layer; this is the interactive
|
||||
-- way in, modeled on `editor.describe-command` directly above and sharing
|
||||
-- its `*help*` buffer handling.
|
||||
--
|
||||
-- The prompt takes free text: `pmacs.minibuffer.read`'s `source` is a
|
||||
-- fixed vocabulary ("commands", "buffers") resolved in Rust, and adding a
|
||||
-- settings source means touching the minibuffer candidate machinery,
|
||||
-- which this arc deliberately stays out of. `pmacs.config.list()` is the
|
||||
-- programmatic way to enumerate names meanwhile; a completion source (and
|
||||
-- an M-x list-settings panel) are named deferrals in the framing.
|
||||
|
||||
local function describe_setting_lines(name, info)
|
||||
-- Header block mirrors help.rs's `format_hook_text`: aligned label
|
||||
-- column, then a blank line, then the description as prose.
|
||||
local lines = {
|
||||
"Setting: " .. name,
|
||||
" Type: " .. tostring(info.type),
|
||||
" Default: " .. tostring(info.default),
|
||||
" Value: " .. tostring(info.value),
|
||||
" Mutability: " .. tostring(info.mutability),
|
||||
" Source: " .. tostring(info.source),
|
||||
}
|
||||
if info.min ~= nil then lines[#lines + 1] = " Min: " .. tostring(info.min) end
|
||||
if info.max ~= nil then lines[#lines + 1] = " Max: " .. tostring(info.max) end
|
||||
if type(info.choices) == "table" and #info.choices > 0 then
|
||||
lines[#lines + 1] = " Choices: " .. table.concat(info.choices, ", ")
|
||||
end
|
||||
lines[#lines + 1] = ""
|
||||
local desc = info.description
|
||||
if type(desc) ~= "string" or desc == "" then desc = "(no description)" end
|
||||
lines[#lines + 1] = desc
|
||||
lines[#lines + 1] = ""
|
||||
-- Overrides. `global` is the global-chain resolution regardless of the
|
||||
-- buffer argument, so "same as default" is a real, distinguishable
|
||||
-- state from "overridden to the same value" only via is_set — which is
|
||||
-- why this reports the resolved values rather than claiming presence.
|
||||
lines[#lines + 1] = "Global value: " .. tostring(info.global)
|
||||
if info.buffer_local ~= nil then
|
||||
lines[#lines + 1] = "Buffer-local override: " .. tostring(info.buffer_local)
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
cmd { name = "editor.describe-setting",
|
||||
description = "Prompt for a setting name and render its definition in *help*.",
|
||||
fn = function()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Describe setting: ",
|
||||
history = "command",
|
||||
on_accept = function(name)
|
||||
if name == nil or name == "" then return end
|
||||
-- An undefined name raises NotFound rather than returning nil
|
||||
-- (the define-before-set posture, Q#CR10), so this must pcall.
|
||||
local buf = pmacs.window.buffer()
|
||||
local ok, info = pcall(pmacs.config.describe, name, buf)
|
||||
if not ok or type(info) ~= "table" then
|
||||
pmacs.editor.set_status("describe-setting: no such setting: " .. name)
|
||||
return
|
||||
end
|
||||
show_help_text(table.concat(describe_setting_lines(name, info), "\n"))
|
||||
end,
|
||||
}
|
||||
end }
|
||||
|
|
|
|||
|
|
@ -24,7 +24,20 @@ pmacs.autosave = pmacs.autosave or {}
|
|||
local DEFAULT_INTERVAL_MS = 30000 -- Emacs's auto-save-timeout
|
||||
local MIN_INTERVAL_MS = 1000 -- each sweep fsyncs; don't storm
|
||||
|
||||
local interval = DEFAULT_INTERVAL_MS
|
||||
-- Migrated to `autosave.interval-ms` (Q#CR8's third adopter: integer,
|
||||
-- validated, re-read live). The tick below re-reads the registry every
|
||||
-- frame — see its comment — so storage moves there instead of a
|
||||
-- module-local, but the wrapper's shape and its lenient coercion (F4)
|
||||
-- stay exactly as they were.
|
||||
pmacs.config.define {
|
||||
name = "autosave.interval-ms",
|
||||
description = "Milliseconds between periodic autosave sweeps.",
|
||||
type = "integer",
|
||||
default = DEFAULT_INTERVAL_MS,
|
||||
min = MIN_INTERVAL_MS,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
local enabled = true
|
||||
local last_sweep_ms = nil
|
||||
-- Report on the first tick (the startup scan), and after every load.
|
||||
|
|
@ -38,14 +51,20 @@ end
|
|||
|
||||
-- interval_ms([ms]) --- getter when `ms` is nil, else a validated setter.
|
||||
-- Shape follows `pmacs.async_config.frame_target_ms`. The tick re-reads
|
||||
-- this every frame, so a change takes effect immediately -- no restart.
|
||||
-- the registry every frame, so a change -- through this wrapper OR a
|
||||
-- direct `pmacs.config.set` -- takes effect immediately, no restart.
|
||||
-- Legacy coercion stays lenient (F4): floor a fractional `ms` FIRST,
|
||||
-- then hand the registry an already-conforming integer, since
|
||||
-- `pmacs.config.set` itself demands exactness. The floor-then-min error
|
||||
-- message and threshold are unchanged.
|
||||
function pmacs.autosave.interval_ms(ms)
|
||||
if ms == nil then return interval end
|
||||
if ms == nil then return pmacs.config.get("autosave.interval-ms") end
|
||||
if type(ms) ~= "number" or ms ~= ms or ms < MIN_INTERVAL_MS then
|
||||
error("pmacs.autosave.interval_ms: expected a number >= " .. MIN_INTERVAL_MS)
|
||||
end
|
||||
interval = math.floor(ms)
|
||||
return interval
|
||||
local floored = math.floor(ms)
|
||||
pmacs.config.set("autosave.interval-ms", floored)
|
||||
return floored
|
||||
end
|
||||
|
||||
-- sweep() --- force a pass now. Returns (written, blocked, conflicted).
|
||||
|
|
@ -113,9 +132,10 @@ end
|
|||
|
||||
-- The cadence (Q#AS2). `process.after-tick` fires every frame -- and the
|
||||
-- run loops tick on a frame *timeout*, not only on input, so this keeps
|
||||
-- running while the editor is idle. Costs one clock read + a compare per
|
||||
-- frame, and parks no worker thread (a long `workers.sleep` would hold
|
||||
-- one of only `available_parallelism - 1` pool threads).
|
||||
-- running while the editor is idle. Costs one clock read, one registry
|
||||
-- get (a borrowed/copied scalar, no Lua table built -- Q#CR15) and a
|
||||
-- compare per frame, and parks no worker thread (a long `workers.sleep`
|
||||
-- would hold one of only `available_parallelism - 1` pool threads).
|
||||
pmacs.hook.add("process.after-tick", function()
|
||||
if needs_report then
|
||||
needs_report = false
|
||||
|
|
@ -127,7 +147,7 @@ pmacs.hook.add("process.after-tick", function()
|
|||
last_sweep_ms = now
|
||||
return
|
||||
end
|
||||
if now - last_sweep_ms >= interval then
|
||||
if now - last_sweep_ms >= pmacs.config.get("autosave.interval-ms") then
|
||||
last_sweep_ms = now
|
||||
sweep_reporting()
|
||||
end
|
||||
|
|
|
|||
|
|
@ -840,11 +840,24 @@ pmacs.command.define {
|
|||
|
||||
-- Opt-in trim-on-save. Getter when nil (the killring.max shape);
|
||||
-- default OFF — rewriting bytes on save is a policy, not a default.
|
||||
local trim_enabled = false
|
||||
-- Migrated to `editing.trim-on-save` (Q#CR8's second adopter) behind
|
||||
-- this unchanged signature. Legacy coercion stays lenient (F4): anything
|
||||
-- but a literal `false` turns it on, same as the boolean-enable shape
|
||||
-- shared by autosave.enable/recentf.enable/saveplace.enable — coerce
|
||||
-- FIRST, then hand the registry an already-conforming boolean, since
|
||||
-- `pmacs.config.set` itself is strict.
|
||||
pmacs.config.define {
|
||||
name = "editing.trim-on-save",
|
||||
description = "Delete trailing whitespace from every line before a save.",
|
||||
type = "boolean",
|
||||
default = false,
|
||||
mutability = "live",
|
||||
}
|
||||
function pmacs.editops.trim_on_save(on)
|
||||
if on == nil then return trim_enabled end
|
||||
trim_enabled = (on ~= false)
|
||||
return trim_enabled
|
||||
if on == nil then return pmacs.config.get("editing.trim-on-save") end
|
||||
local enabled = (on ~= false)
|
||||
pmacs.config.set("editing.trim-on-save", enabled)
|
||||
return enabled
|
||||
end
|
||||
|
||||
-- Registered at load time (gated inside) so it runs BEFORE
|
||||
|
|
@ -861,7 +874,7 @@ pmacs.hook.add("buffer.before-save", function()
|
|||
-- either way). Both reports are pcall'd — a broken reporting
|
||||
-- channel must not resurrect the veto.
|
||||
local ok, err = pcall(function()
|
||||
if trim_enabled then
|
||||
if pmacs.config.get("editing.trim-on-save") then
|
||||
trim_active("delete-trailing-whitespace (on save)")
|
||||
end
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,20 @@ local ed = pmacs.editor
|
|||
-- daemon-peer op, so its undo is cross-peer-degraded (documented
|
||||
-- limitation; the general fix is chronological cross-peer undo
|
||||
-- arbitration, named substrate work).
|
||||
|
||||
-- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the
|
||||
-- SOURCE buffer of the typed edit, never the currently active one — see
|
||||
-- the hook body below, which resolves it the same way `set_for` resolves
|
||||
-- the buffer's pair set (round 2, finding 2): `rec.buffer`, not
|
||||
-- `pmacs.window.buffer()`.
|
||||
pmacs.config.define {
|
||||
name = "editing.auto-pair",
|
||||
description = "Automatically insert (and skip over) the closing half of a typed pair.",
|
||||
type = "boolean",
|
||||
default = true,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
pmacs.pair.sets = {
|
||||
default = { "()", "[]", "{}", '""' },
|
||||
python = { "()", "[]", "{}", '""', "''" },
|
||||
|
|
@ -198,6 +212,12 @@ pmacs.hook.add("buffer.after-edit", function()
|
|||
if not rec then return end
|
||||
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
|
||||
|
||||
-- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the
|
||||
-- typed edit, resolved buffer-local -> global -> default(true). A
|
||||
-- second buffer of the same language is untouched by a buffer-local
|
||||
-- override here (acceptance 29).
|
||||
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end
|
||||
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,29 @@ global-only scoping (Q#CR4), `editor.tab_width` as the proving adopter
|
|||
- **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`)
|
||||
|
|
@ -576,9 +599,22 @@ the posture `require_init_phase` (`mod.rs:663`) already hard-codes for
|
|||
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. `define` rejects the combination outright with
|
||||
`StartupOnlyLocal` rather than shipping a knob whose two halves
|
||||
contradict each other.
|
||||
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
|
||||
|
|
@ -598,8 +634,18 @@ later-defined names is deferred.
|
|||
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, deterministic by
|
||||
key. Neither ever exposes an internal table or a listener function.
|
||||
`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
|
||||
|
|
@ -640,13 +686,39 @@ 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 — Surface in `config.lua`; each module defines its own keys
|
||||
### Q#CR14 — No runtime chunk; each module defines its own keys
|
||||
|
||||
`builtin/runtime/config.lua` holds the friendly Lua surface only, loaded
|
||||
in `EditorState::new()` immediately after `fs.lua` (`editor.rs:206`) and
|
||||
before every module that defines or reads a setting — in particular
|
||||
before `pair.lua` (`editor.rs:319`), whose own load-before-`lsp.lua`
|
||||
contract (`editor.rs:325`) is unaffected.
|
||||
**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
|
||||
|
|
@ -762,8 +834,9 @@ Registry semantics (unit, `src/config_registry.rs`):
|
|||
6. Integer and number finite/boundary cases are exact under **both**
|
||||
`--features luajit` (default) and
|
||||
`--no-default-features --features lua54`.
|
||||
7. `list` is deterministic by key; `describe`/`list` never expose an
|
||||
internal mutable table or a listener function.
|
||||
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
|
||||
|
|
@ -817,13 +890,20 @@ Startup and mutability:
|
|||
`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. `define` rejects `mutability = 'startup'` combined with any
|
||||
`set_local` attempt, via `StartupOnlyLocal` (F5).
|
||||
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. `config.lua` is installed before `pair.lua`, and `pair.lua` still
|
||||
loads before `lsp.lua` (the Q#AP7 contract is untouched).
|
||||
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:
|
||||
|
||||
|
|
@ -839,9 +919,18 @@ Adopters:
|
|||
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).
|
||||
31. Neither adopter performs a per-frame or per-cell Lua lookup, and
|
||||
both preserve their previous default behavior exactly.
|
||||
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:
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -55,6 +55,7 @@ pub mod command;
|
|||
pub mod completion;
|
||||
pub mod completion_framework;
|
||||
pub mod config;
|
||||
pub mod config_registry;
|
||||
// T M10.2: CRDT-backed buffer state. Feature-gated so v0.1 builds
|
||||
// carry zero overhead — the `loro` dependency isn't pulled in, no
|
||||
// field on the Buffer struct layout, no branch on `apply_edit`.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -82,6 +82,7 @@ use crate::workers_buffer;
|
|||
// beyond call seams. Public entry points a domain owns are re-exported here
|
||||
// so external `crate::lua_bindings::<item>` paths (and in-file uses) stay
|
||||
// stable.
|
||||
mod config;
|
||||
mod diag;
|
||||
mod index;
|
||||
mod mcp;
|
||||
|
|
@ -1459,6 +1460,9 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) {
|
|||
if let Some(keymaps) = lua.app_data_ref::<SharedKeymapStack>() {
|
||||
keymaps.borrow_mut().remove_buffer(id);
|
||||
}
|
||||
if let Some(config) = lua.app_data_ref::<config::SharedConfigRegistry>() {
|
||||
config.borrow_mut().remove_buffer(id);
|
||||
}
|
||||
let callbacks = match lua.app_data_ref::<BufferRemoveCallbacks>() {
|
||||
Some(callbacks) => callbacks.take(id),
|
||||
None => Vec::new(),
|
||||
|
|
@ -2277,6 +2281,9 @@ pub fn install(
|
|||
lua.set_app_data(BufferRemoveCallbacks::new());
|
||||
let statusline = Rc::new(RefCell::new(StatuslineRegistry::new()));
|
||||
lua.set_app_data(statusline.clone());
|
||||
let config_registry: config::SharedConfigRegistry =
|
||||
Rc::new(RefCell::new(crate::config_registry::ConfigRegistry::new()));
|
||||
lua.set_app_data(config_registry.clone());
|
||||
|
||||
let pmacs = lua.create_table()?;
|
||||
pmacs.set("buffer", install_buffer_module(lua, registry)?)?;
|
||||
|
|
@ -2285,6 +2292,7 @@ pub fn install(
|
|||
pmacs.set("menu", install_menu_module(lua, menus)?)?;
|
||||
pmacs.set("hook", install_hook_module(lua, hooks)?)?;
|
||||
pmacs.set("statusline", install_statusline_module(lua, &statusline)?)?;
|
||||
pmacs.set("config", config::install_config(lua, &config_registry)?)?;
|
||||
// Wall-clock millis (since UNIX epoch). Used by builtin runtime
|
||||
// chunks for timeout loops; `os.clock()` only counts CPU time and
|
||||
// is a poor fit for "wait until something arrives over I/O".
|
||||
|
|
|
|||
|
|
@ -0,0 +1,415 @@
|
|||
//! Config-registry acceptance (docs/config-registry-framing.md).
|
||||
//!
|
||||
//! The registry's own semantics are unit-tested in
|
||||
//! `src/config_registry.rs` (value/scope/epoch/listener behavior) and
|
||||
//! `src/lua_bindings/config.rs` (the Lua boundary). This suite covers
|
||||
//! only what those cannot reach: the three adopters wired into a real
|
||||
//! `EditorState`, the owner-defines source-location contract observed
|
||||
//! after actual chunk load, and `M-x describe-setting` rendering
|
||||
//! through the real minibuffer.
|
||||
//!
|
||||
//! Framing acceptance items covered here: 9, 19, 26, 27, 28, 29, 30, 33.
|
||||
//!
|
||||
//! Pairing is exercised by DISPATCHING keys, never
|
||||
//! `pmacs.command.invoke` — pair.lua reacts to `buffer.after-edit`
|
||||
//! with a typed-edit record that only real dispatch produces, so an
|
||||
//! invoke-driven test would pass vacuously against a broken gate.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::lua_bindings::StateDir;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (mirrors tests/auto_pair_acceptance.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn fresh_state_dir() -> PathBuf {
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pmacs-configreg-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn editor(state_dir: &std::path::Path) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
s.lua_host.lua().remove_app_data::<StateDir>();
|
||||
s.lua_host
|
||||
.lua()
|
||||
.set_app_data(StateDir(state_dir.to_path_buf()));
|
||||
// Language DETECTION must work; server SPAWNING must not (rust and
|
||||
// python carry default configs and the after-load hook would spawn
|
||||
// real servers).
|
||||
exec(&s, "pmacs.lsp.config = {}");
|
||||
s
|
||||
}
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
p.display().to_string()
|
||||
}
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn active_text(s: &EditorState) -> String {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||
}
|
||||
|
||||
/// `show_help_text` ends by switching the window to `*help*`, so after
|
||||
/// a describe command the active buffer IS the help buffer. Asserting
|
||||
/// through the active buffer also proves the switch happened, which a
|
||||
/// direct by-name lookup would silently tolerate skipping.
|
||||
fn help_text(s: &EditorState) -> String {
|
||||
let name: String = eval(s, "return pmacs.window.buffer():name()");
|
||||
assert_eq!(name, "*help*", "the describe command must display *help*");
|
||||
active_text(s)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 26 / 9 — owner-defines, observed after real chunk load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn builtin_defines_succeed_at_chunk_load_and_report_their_owning_module() {
|
||||
let s = editor(&fresh_state_dir());
|
||||
|
||||
// Item 26: the define calls at the top of pair.lua / editops.lua /
|
||||
// autosave.lua ran during EditorState::new, which is only possible
|
||||
// if pmacs.config was populated before the first runtime chunk.
|
||||
for name in [
|
||||
"editing.auto-pair",
|
||||
"editing.trim-on-save",
|
||||
"autosave.interval-ms",
|
||||
] {
|
||||
let known: bool = eval(
|
||||
&s,
|
||||
&format!("return pmacs.config.describe({name:?}) ~= nil"),
|
||||
);
|
||||
assert!(known, "{name} must be defined by its owning module");
|
||||
}
|
||||
|
||||
// Item 9: each definition's SourceLocation points at the module
|
||||
// that owns the setting, not at a shared helper. This is exactly
|
||||
// what a centralized define table in a config.lua would have
|
||||
// broken (framing Q#CR14).
|
||||
let pair_src: String = eval(
|
||||
&s,
|
||||
"return pmacs.config.describe('editing.auto-pair').source",
|
||||
);
|
||||
assert!(
|
||||
pair_src.contains("pair.lua"),
|
||||
"editing.auto-pair must report pair.lua as its source, got {pair_src:?}"
|
||||
);
|
||||
let trim_src: String = eval(
|
||||
&s,
|
||||
"return pmacs.config.describe('editing.trim-on-save').source",
|
||||
);
|
||||
assert!(
|
||||
trim_src.contains("editops.lua"),
|
||||
"editing.trim-on-save must report editops.lua, got {trim_src:?}"
|
||||
);
|
||||
let auto_src: String = eval(
|
||||
&s,
|
||||
"return pmacs.config.describe('autosave.interval-ms').source",
|
||||
);
|
||||
assert!(
|
||||
auto_src.contains("autosave.lua"),
|
||||
"autosave.interval-ms must report autosave.lua, got {auto_src:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 29 — the flagship: per-buffer auto-pair, the feature this arc exists for
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn auto_pair_off_buffer_locally_suppresses_only_that_buffer() {
|
||||
let dir = fresh_state_dir();
|
||||
let mut s = editor(&dir);
|
||||
let a = write_file(&dir, "a.rs", "");
|
||||
let b = write_file(&dir, "b.rs", "");
|
||||
|
||||
// Two buffers of the SAME language — so a language-keyed
|
||||
// implementation could not pass this test. The handle is stashed in
|
||||
// a Lua global because buffer handles are userdata, not ids.
|
||||
exec(&s, &format!("BUF_A = pmacs.buffer.find_or_open({a:?})"));
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({b:?})"));
|
||||
|
||||
// Turn pairing off in buffer A only.
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.config.set_local(BUF_A, 'editing.auto-pair', false)",
|
||||
);
|
||||
|
||||
// B (untouched) still pairs.
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, "(");
|
||||
assert_eq!(
|
||||
active_text(&s),
|
||||
"()",
|
||||
"a buffer with no local override still pairs"
|
||||
);
|
||||
|
||||
// A (overridden) does not.
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, "(");
|
||||
assert_eq!(
|
||||
active_text(&s),
|
||||
"(",
|
||||
"the buffer-local override must suppress pairing in THIS buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pair_off_globally_suppresses_everywhere() {
|
||||
let dir = fresh_state_dir();
|
||||
let mut s = editor(&dir);
|
||||
let a = write_file(&dir, "a.rs", "");
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
exec(&s, "pmacs.config.set('editing.auto-pair', false)");
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, "(");
|
||||
assert_eq!(active_text(&s), "(", "a global false suppresses pairing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pair_defaults_on_so_the_migration_changed_no_default() {
|
||||
let dir = fresh_state_dir();
|
||||
let mut s = editor(&dir);
|
||||
let a = write_file(&dir, "a.rs", "");
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, "(");
|
||||
assert_eq!(active_text(&s), "()", "pairing is on by default");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Items 27 / 28 — the migration wrappers keep their legacy coercion (F4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn trim_on_save_wrapper_and_registry_are_interchangeable_both_ways() {
|
||||
let s = editor(&fresh_state_dir());
|
||||
|
||||
// Wrapper write observed by the registry.
|
||||
exec(&s, "pmacs.editops.trim_on_save(true)");
|
||||
let via_registry: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')");
|
||||
assert!(via_registry, "the wrapper's write must reach the registry");
|
||||
|
||||
// Registry write observed by the wrapper.
|
||||
exec(&s, "pmacs.config.set('editing.trim-on-save', false)");
|
||||
let via_wrapper: bool = eval(&s, "return pmacs.editops.trim_on_save()");
|
||||
assert!(
|
||||
!via_wrapper,
|
||||
"the registry's write must be visible through the wrapper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_on_save_keeps_its_lenient_truthiness() {
|
||||
// F4: the registry is strict (a real boolean or nothing), but this
|
||||
// legacy setter has always accepted anything that is not literally
|
||||
// `false`. A thin wrapper over a strict `set` would raise here.
|
||||
let s = editor(&fresh_state_dir());
|
||||
exec(&s, "pmacs.editops.trim_on_save('yes')");
|
||||
let on: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')");
|
||||
assert!(on, "a non-false argument must still turn trimming on");
|
||||
|
||||
exec(&s, "pmacs.editops.trim_on_save(false)");
|
||||
let off: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')");
|
||||
assert!(!off, "a literal false must still turn it off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_ms_keeps_flooring_a_fractional_argument() {
|
||||
// F4 again: `integer` demands exactness, so the wrapper must floor
|
||||
// BEFORE handing the value over. Pre-migration this returned 1500.
|
||||
let s = editor(&fresh_state_dir());
|
||||
let got: i64 = eval(&s, "return pmacs.autosave.interval_ms(1500.7)");
|
||||
assert_eq!(got, 1500, "a fractional interval floors, it does not raise");
|
||||
let stored: i64 = eval(&s, "return pmacs.config.get('autosave.interval-ms')");
|
||||
assert_eq!(stored, 1500, "and the floored value is what was stored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_ms_still_raises_below_the_floor() {
|
||||
let s = editor(&fresh_state_dir());
|
||||
let raised: bool = eval(
|
||||
&s,
|
||||
"local ok = pcall(pmacs.autosave.interval_ms, 500); return not ok",
|
||||
);
|
||||
assert!(raised, "sub-floor intervals must still raise");
|
||||
let unchanged: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
|
||||
assert_eq!(unchanged, 30000, "a rejected set leaves the value alone");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 30 — a direct registry write is what the tick will read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn interval_change_through_the_registry_is_visible_to_the_cadence_reader() {
|
||||
// The tick re-reads `pmacs.config.get("autosave.interval-ms")` every
|
||||
// frame rather than a module-local, so a mid-session change through
|
||||
// EITHER path applies without a restart. Asserting through the
|
||||
// wrapper's getter proves the module-local is really gone: a stale
|
||||
// upvalue would still report 30000 here.
|
||||
let s = editor(&fresh_state_dir());
|
||||
exec(&s, "pmacs.config.set('autosave.interval-ms', 5000)");
|
||||
let seen: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
|
||||
assert_eq!(
|
||||
seen, 5000,
|
||||
"a direct registry write must be what the cadence reads"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 19 — user config runs after builtins define, so a set in init.lua lands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_set_in_user_config_position_is_observed_by_the_consumer() {
|
||||
// EditorState::new does not load user config in test builds, so
|
||||
// this drives the same ORDER explicitly: every builtin has defined,
|
||||
// and a user-config-shaped `set` now runs against those names and
|
||||
// is observed by the adopter that owns each one.
|
||||
let dir = fresh_state_dir();
|
||||
let mut s = editor(&dir);
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
pmacs.config.set("editing.auto-pair", false)
|
||||
pmacs.config.set("editing.trim-on-save", true)
|
||||
pmacs.config.set("autosave.interval-ms", 9000)
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
eval::<bool>(&s, "return pmacs.editops.trim_on_save()"),
|
||||
"editops observes the user-config write"
|
||||
);
|
||||
assert_eq!(
|
||||
eval::<i64>(&s, "return pmacs.autosave.interval_ms()"),
|
||||
9000,
|
||||
"autosave observes the user-config write"
|
||||
);
|
||||
let a = write_file(&dir, "a.rs", "");
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, "(");
|
||||
assert_eq!(
|
||||
active_text(&s),
|
||||
"(",
|
||||
"pair.lua observes the user-config write"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 33 — M-x describe-setting renders into *help*
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn describe_setting_renders_into_help_with_the_source_location() {
|
||||
let s0 = editor(&fresh_state_dir());
|
||||
let mut s = s0;
|
||||
exec(&s, "pmacs.command.invoke('editor.describe-setting')");
|
||||
type_str(&mut s, "editing.auto-pair");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let text = help_text(&s);
|
||||
assert!(
|
||||
text.contains("Setting: editing.auto-pair"),
|
||||
"*help* must carry the setting header, got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("pair.lua"),
|
||||
"the rendered source location must name the owning module, got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Type:") && text.contains("boolean"),
|
||||
"the type must be rendered, got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Mutability:") && text.contains("live"),
|
||||
"mutability must be rendered, got {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_setting_reports_an_unknown_name_in_the_status_line() {
|
||||
// `describe` RAISES NotFound for an undefined name rather than
|
||||
// returning nil (define-before-set, Q#CR10), so the command must
|
||||
// pcall — without it this dispatch would surface a Lua traceback.
|
||||
let s0 = editor(&fresh_state_dir());
|
||||
let mut s = s0;
|
||||
exec(&s, "pmacs.command.invoke('editor.describe-setting')");
|
||||
type_str(&mut s, "editing.no-such-setting");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let status = s.core.borrow().status.clone();
|
||||
assert!(
|
||||
format!("{status:?}").contains("no such setting"),
|
||||
"an unknown name must report cleanly, got {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_setting_shows_a_buffer_local_override_when_one_exists() {
|
||||
let dir = fresh_state_dir();
|
||||
let mut s = editor(&dir);
|
||||
let a = write_file(&dir, "a.rs", "");
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.config.set_local(pmacs.window.buffer(), 'editing.auto-pair', false)",
|
||||
);
|
||||
exec(&s, "pmacs.command.invoke('editor.describe-setting')");
|
||||
type_str(&mut s, "editing.auto-pair");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let text = help_text(&s);
|
||||
assert!(
|
||||
text.contains("Buffer-local override:"),
|
||||
"an existing buffer-local override must be reported, got {text:?}"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue