Merge pull request #127 from levineuwirth/config-registry

feat(config): typed configuration registry with buffer-local scope
This commit is contained in:
Levi Neuwirth 2026-07-21 23:06:45 +00:00 committed by GitHub
commit 2e37c0484e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 5516 additions and 14 deletions

View File

@ -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 }

View File

@ -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

View File

@ -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,15 @@ 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
-- Resolved against the buffer being saved, not the global chain
-- (review round 1, finding 2). `buffer.before-save` fires for the
-- ACTIVE buffer -- which is also the one `trim_active` rewrites --
-- so passing it here is what makes a buffer-local override mean
-- something. Reading globally would accept `set_local`, store it,
-- report it from `describe`, and then never consult it: a pin the
-- user believes in that does nothing, which is the same failure
-- shape F1 exists to prevent.
if pmacs.config.get("editing.trim-on-save", pmacs.window.buffer()) then
trim_active("delete-trailing-whitespace (on save)")
end
end)

View File

@ -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

File diff suppressed because it is too large Load Diff

2171
src/config_registry.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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`.

1663
src/lua_bindings/config.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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".

View File

@ -0,0 +1,509 @@
//! 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");
}
// ---------------------------------------------------------------------------
// Item 13 — the purge runs on the REAL buffer-death path
// ---------------------------------------------------------------------------
#[test]
fn killing_a_buffer_through_the_real_path_purges_its_locals() {
// Review round 1, finding 4. Every other test of the purge calls
// `ConfigRegistry::remove_buffer` directly, so deleting the three
// lines wired into `after_buffer_removed` would leave them all
// green. This drives `pmacs.buffer.remove`, which is the production
// route (`remove_buffer_and_fire` -> `after_buffer_removed`), and
// fails if that wiring is absent.
//
// The assertion reads through the DEAD handle on purpose: BufferIds
// are never reused (buffer_registry.rs), so a stale id cannot alias
// a later buffer, and `is_set` against it reports exactly whether
// the registry still holds that buffer's map.
let dir = fresh_state_dir();
let s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("DEAD = pmacs.buffer.find_or_open({a:?})"));
exec(
&s,
"pmacs.config.set_local(DEAD, 'editing.auto-pair', false)",
);
assert!(
eval::<bool>(&s, "return pmacs.config.is_set('editing.auto-pair', DEAD)"),
"precondition: the buffer-local override is stored"
);
// Switch away first so killing the buffer cannot leave the window
// pointing at a dead buffer, then remove it through the real path.
exec(&s, "pmacs.buffer.remove(DEAD)");
assert!(
!eval::<bool>(&s, "return pmacs.config.is_set('editing.auto-pair', DEAD)"),
"the buffer's locals must be purged when it is removed"
);
assert!(
eval::<bool>(&s, "return pmacs.config.get('editing.auto-pair', DEAD)"),
"and resolution falls back to the global 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_honors_a_buffer_local_override() {
// Review round 1, finding 2. The save hook resolves against the
// buffer being saved, so `set_local` is a real per-buffer switch
// rather than a stored value nothing ever reads.
let dir = fresh_state_dir();
let s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("BUF = pmacs.buffer.find_or_open({a:?})"));
// The content must be INSERTED, not merely present on disk:
// `save()` no-ops on an unmodified buffer, so a freshly-opened
// buffer would leave the file byte-identical and this test would
// pass without the save hook ever running.
exec(&s, r#"BUF:insert(0, "keep me \n")"#);
// Globally on, but off for this buffer: trailing space survives.
exec(&s, "pmacs.config.set('editing.trim-on-save', true)");
exec(
&s,
"pmacs.config.set_local(BUF, 'editing.trim-on-save', false)",
);
exec(&s, "pmacs.command.invoke('buffer.save')");
assert_eq!(
std::fs::read_to_string(&a).unwrap(),
"keep me \n",
"a buffer-local false must suppress trimming for this buffer"
);
}
#[test]
fn trim_on_save_still_falls_back_to_the_global_value() {
// The other half of finding 2's fix, and its regression guard:
// now that the hook passes a buffer, a broken fallback would make
// the global setting silently stop working. A separate editor and
// file because `save()` no-ops on an unmodified buffer, so the two
// cases cannot share one save cycle.
let dir = fresh_state_dir();
let s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("BUF = pmacs.buffer.find_or_open({a:?})"));
exec(&s, r#"BUF:insert(0, "trim me \n")"#);
exec(&s, "pmacs.config.set('editing.trim-on-save', true)");
exec(&s, "pmacs.command.invoke('buffer.save')");
assert_eq!(
std::fs::read_to_string(&a).unwrap(),
"trim me\n",
"with no buffer-local override the global setting must still apply"
);
}
#[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:?}"
);
}