pmacs/src/lua_bindings/config.rs

1664 lines
62 KiB
Rust

// lua_bindings/config.rs --- pmacs.config: the configuration registry surface.
//! `pmacs.config.*` --- the Lua surface over [`crate::config_registry`].
//! Per `docs/config-registry-framing.md`: two scopes, no wire surface,
//! and no runtime chunk (Q#CR14) --- `pmacs.config` is installed here,
//! entirely from Rust, before any `builtin/runtime/*.lua` chunk
//! evaluates, exactly like `pmacs.command` and `pmacs.hook` have no
//! Lua-side companion file either.
//!
//! ```lua
//! pmacs.config.define { name = ..., description = ..., type = ...,
//! default = ..., min = ..., max = ...,
//! choices = ..., allow_empty = ...,
//! mutability = ... }
//! pmacs.config.get(name [, buf])
//! pmacs.config.set(name, value)
//! pmacs.config.set_local(buf, name, value)
//! pmacs.config.reset(name [, buf])
//! pmacs.config.is_set(name [, buf])
//! pmacs.config.describe(name [, buf])
//! pmacs.config.list()
//! local handle = pmacs.config.on_change(name, function(new, old, buf) ... end)
//! handle:dispose()
//! ```
//!
//! # Two scopes, no ambient buffer (Q#CR4, F9)
//!
//! `set` writes the global override; `set_local` writes one buffer's
//! override. `get(name, buf)` resolves buffer-local -> global ->
//! default. **`get(name)` with no buffer argument resolves the global
//! chain only** --- global override, then default --- and never
//! consults an "active" buffer. There is no ambient buffer at this
//! layer; a caller that wants buffer-aware behavior must pass one.
//! `describe` and `list` follow the identical rule for their `value`
//! field.
//!
//! # Define before set; owner-defines, not a runtime chunk (Q#CR10, Q#CR14)
//!
//! Every name must be `define`d before `get`/`set`/`set_local`/
//! `reset`/`is_set`/`describe`/`on_change` touches it --- an undefined
//! name raises `NotFound`, the same posture `pmacs.hook.add` already
//! has. Definitions live with the module that owns the setting, not in
//! a shared helper: `pair.lua` defines `editing.auto-pair`,
//! `editops.lua` defines `editing.trim-on-save`, `autosave.lua`
//! defines `autosave.interval-ms`. [`SourceLocation`] is captured from
//! Lua debug info at the `define` call site (see [`caller_source`]),
//! so a shared wrapper would point every builtin setting's reported
//! source at the wrapper instead of its owner --- which is exactly why
//! no such wrapper exists here.
//!
//! # Strict specs, lenient wrappers (Q#CR3, Q#CR8)
//!
//! `type` is one of `boolean | integer | number | string | enum` ---
//! no list/table type in stage 1. `define`'s spec table is read with
//! **raw** access only (typo-detection via [`Table::pairs`], field
//! reads via [`Table::raw_get`]), so neither an unknown key nor a
//! metatable-provided value can smuggle a field in (R50). Unknown
//! keys, a missing-or-whitespace-only `description` (R42), and a
//! `default` that violates its own `min`/`max`/`choices` are all
//! rejected before anything is registered. `set`/`set_local` enforce
//! the identical type and bounds on every write --- this module
//! converts a raw [`Value`] into a [`ConfigValue`] and lets
//! [`ConfigRegistry`] own the value-level check (see that module's
//! "value-validation seam").
//!
//! That strictness is deliberate and is not this module's to relax for
//! any particular adopter: `pmacs.editops.trim_on_save("yes")` and
//! `pmacs.autosave.interval_ms(1500.7)` keep their legacy coercion
//! (flooring, `~= false`) in their own builtin files, calling `set`
//! only with an already-conforming value. This registry never coerces.
//!
//! # Integer exactness, by value, never `math.type` (acceptance 6)
//!
//! `LuaJIT` (Lua 5.1 semantics) never produces `Value::Integer` --- every
//! `LuaJIT` number arrives as `Value::Number(f64)`. Lua 5.4 produces
//! `Value::Integer(i64)` for integer literals, which are already exact
//! and read straight through with no float round-trip (round-tripping
//! a large `i64` through `f64` can silently change its value).
//! `Value::Number` on either backend goes through
//! [`ConfigValue::int_from_f64`], which checks exactness by the
//! numeric value alone. Neither backend's arm inspects which backend
//! produced the value or what `math.type` would say.
//!
//! # Listener dispatch (Q#CR6)
//!
//! `set`/`set_local`/`reset` commit inside the registry borrow, then
//! (iff the effective value changed) drop that borrow completely
//! before invoking any listener body --- see [`dispatch_config_listeners`].
//! A raising listener is logged and does not stop later listeners or
//! roll back the committed value. [`ConfigDispatchDepth`] bounds
//! re-entrant dispatch so an accidental listener cycle raises a
//! pointed error instead of recursing forever. Listeners persist until
//! explicitly disposed (F3): there is no `MetaMethod::Gc` anywhere in
//! this module, matching the rest of the codebase's explicit-dispose
//! posture.
//!
//! # The startup freeze, without touching `editor.rs`
//!
//! [`ConfigMutability::StartupOnly`] enforcement is entirely
//! [`ConfigRegistry`]'s job (`StartupOnlyAfterFreeze`); this module's
//! only responsibility is calling [`ConfigRegistry::freeze`] at the
//! right moment. `set` and `reset` do so lazily: if
//! [`super::InitCompleteFlag`] reports the init phase complete and the
//! registry isn't frozen yet, they freeze it before proceeding. That
//! keeps the single source of truth for "init is done" in the flag
//! `EditorState::new` already flips, with no new call added to
//! `editor.rs`.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use mlua::{Function, Lua, Table, UserData, UserDataMethods, Value};
use thiserror::Error;
use super::{BufferIdLua, InitCompleteFlag, SharedRegistry, caller_source, require_string_key};
use crate::buffer::EditOp;
use crate::command::SourceLocation;
use crate::config_registry::{
ConfigChange, ConfigError, ConfigKind, ConfigMutability, ConfigRegistry, ConfigValue,
};
/// Shared, single-threaded handle to the configuration registry.
/// Lives behind `Rc<RefCell<...>>` as Lua app data, exactly like the
/// command and hook registries (`mod.rs:2265-2271`).
pub type SharedConfigRegistry = Rc<RefCell<ConfigRegistry>>;
/// Recursion bound for re-entrant listener dispatch (Q#CR6). A
/// listener is free to call `pmacs.config.set`/`set_local`/`reset`
/// re-entrantly --- the registry borrow is always released before any
/// listener body runs (see [`dispatch_config_listeners`]) --- but an
/// accidental cycle (A's listener sets B, B's listener sets A, ...)
/// must not hang the editor. Chosen generously above any legitimate
/// re-entrant chain a real adopter would produce.
const MAX_DISPATCH_DEPTH: u32 = 32;
/// Per-VM re-entrant listener-dispatch depth counter. Newtype app data
/// (mirrors [`InitCompleteFlag`]'s `Rc<Cell<...>>` shape) so
/// `set`/`set_local`/`reset` dispatch calls made from arbitrarily
/// nested re-entrant `pmacs.config.*` calls all see the same counter.
#[derive(Clone)]
struct ConfigDispatchDepth(Rc<Cell<u32>>);
impl ConfigDispatchDepth {
fn new() -> Self {
Self(Rc::new(Cell::new(0)))
}
}
/// RAII guard: increments on construction, decrements on drop ---
/// including on an early `?` return from a failed Lua value
/// conversion between the increment and the dispatch loop --- so the
/// counter can never get stuck above zero.
struct DispatchDepthGuard(Rc<Cell<u32>>);
impl Drop for DispatchDepthGuard {
fn drop(&mut self) {
self.0.set(self.0.get().saturating_sub(1));
}
}
/// Errors raised by this Lua-boundary module itself, distinct from
/// [`ConfigError`]: these describe a failure to make sense of a raw
/// Lua value (an unrecognized `type =` tag, a missing one, a listener
/// cycle) rather than a failure of already-typed config data. See the
/// module doc's "value-validation seam" cross-reference.
#[derive(Debug, Error)]
enum ConfigBindingError {
/// `define` was called without a `type` field.
#[error(
"config spec requires a \"type\" field (one of boolean, integer, number, string, enum)"
)]
MissingType,
/// `define`'s `type` field wasn't one of the closed vocabulary.
#[error("unknown config type `{got}`; expected one of: boolean, integer, number, string, enum")]
UnknownType {
/// The offending type tag.
got: String,
},
/// `define`'s `type = "enum"` had no (or an empty) `choices` table.
#[error("config \"{name}\" (type = \"enum\") requires a `choices` table of strings")]
MissingChoices {
/// The config name being defined.
name: String,
},
/// `define` carried a spec field that means nothing for the
/// declared `type` --- `choices` on a string, `min` on a boolean.
/// Correctly spelled, wrongly placed: the R50 whitelist cannot see
/// it, so it is checked against the kind instead.
#[error(
"config field `{field}` is meaningless for type = \"{got_type}\"; \
`min`/`max` require integer or number, `choices` requires enum, \
`allow_empty` requires string"
)]
FieldIrrelevantForType {
/// The offending field name.
field: &'static str,
/// The declared type it was paired with.
got_type: String,
},
/// `define`'s `mutability` field wasn't `"live"` or `"startup"`.
#[error("unknown config mutability `{got}`; expected one of: live, startup")]
UnknownMutability {
/// The offending mutability tag.
got: String,
},
/// Re-entrant listener dispatch exceeded [`MAX_DISPATCH_DEPTH`] ---
/// almost certainly an accidental listener cycle.
#[error(
"config listener dispatch exceeded the recursion bound ({max}); a listener cycle is likely"
)]
ListenerCycle {
/// The bound that was exceeded.
max: u32,
},
}
// ---------------------------------------------------------------------------
// Lua Value <-> ConfigValue conversion
// ---------------------------------------------------------------------------
fn type_mismatch(name: &str, expected: &'static str, got: &Value) -> mlua::Error {
mlua::Error::external(ConfigError::TypeMismatch {
name: name.to_owned(),
expected,
got: got.type_name(),
})
}
/// Convert a raw Lua number into an exact `i64`. See the module doc's
/// "integer exactness" section: `Value::Integer` (lua54 only) is
/// already exact and is never round-tripped through `f64`;
/// `Value::Number` (both backends) goes through
/// [`ConfigValue::int_from_f64`].
fn lua_exact_i64(name: &str, value: Value) -> mlua::Result<i64> {
match value {
Value::Integer(i) => Ok(i),
Value::Number(f) => match ConfigValue::int_from_f64(name, f) {
Ok(ConfigValue::Int(i)) => Ok(i),
Ok(_) => unreachable!("int_from_f64 always returns ConfigValue::Int"),
Err(e) => Err(mlua::Error::external(e)),
},
other => Err(type_mismatch(name, "integer", &other)),
}
}
fn lua_to_f64(name: &str, value: Value) -> mlua::Result<f64> {
match value {
Value::Integer(i) => Ok(i as f64),
Value::Number(f) => Ok(f),
other => Err(type_mismatch(name, "number", &other)),
}
}
/// Convert a raw Lua value into a [`ConfigValue`] under an
/// already-known [`ConfigKind`]. Used for both `define`'s `default`
/// field and `set`/`set_local`'s value argument --- the one converter
/// both paths share, so there is exactly one place Lua-level type
/// coercion can drift (mirrors [`ConfigRegistry::validate`] being the
/// single value-level seam on the Rust side).
fn lua_to_config_value(name: &str, kind: &ConfigKind, value: Value) -> mlua::Result<ConfigValue> {
match kind {
ConfigKind::Boolean => match value {
Value::Boolean(b) => Ok(ConfigValue::Bool(b)),
other => Err(type_mismatch(name, "boolean", &other)),
},
ConfigKind::Integer { .. } => Ok(ConfigValue::Int(lua_exact_i64(name, value)?)),
ConfigKind::Number { .. } => Ok(ConfigValue::Num(lua_to_f64(name, value)?)),
ConfigKind::String { .. } => match value {
Value::String(s) => Ok(ConfigValue::Str(s.to_str()?.to_owned())),
other => Err(type_mismatch(name, "string", &other)),
},
ConfigKind::Enum { .. } => match value {
Value::String(s) => Ok(ConfigValue::Str(s.to_str()?.to_owned())),
other => Err(type_mismatch(name, "enum", &other)),
},
}
}
fn config_value_to_lua(lua: &Lua, v: &ConfigValue) -> mlua::Result<Value> {
Ok(match v {
ConfigValue::Bool(b) => Value::Boolean(*b),
ConfigValue::Int(i) => Value::Integer(*i),
ConfigValue::Num(f) => Value::Number(*f),
ConfigValue::Str(s) => Value::String(lua.create_string(s)?),
})
}
// ---------------------------------------------------------------------------
// define(): strict raw-table spec parsing
// ---------------------------------------------------------------------------
/// The closed set of keys `pmacs.config.define {...}` accepts. Checked
/// with raw table access (R50) --- see [`check_unknown_fields`].
const DEFINE_SPEC_FIELDS: &[&str] = &[
"name",
"description",
"type",
"default",
"min",
"max",
"choices",
"allow_empty",
"mutability",
];
/// R50 typo-detection: every key actually present in the raw table
/// (via [`Table::pairs`], which --- like [`Table::raw_get`] below ---
/// never invokes `__pairs`/`__index`) must be in `allowed`, or the
/// spec is rejected naming the offender and the supported-key list.
/// This alone doesn't stop a metatable `__index` from answering a
/// `raw_get` for a key the table itself never had; that's why every
/// field read below uses `raw_get`, not `get`, too.
fn check_unknown_fields(spec: &Table, allowed: &[&str]) -> mlua::Result<()> {
for pair in spec.clone().pairs::<Value, Value>() {
let (k, _) = pair?;
let key = require_string_key(k)?;
if !allowed.contains(&key.as_str()) {
return Err(mlua::Error::external(ConfigError::UnknownField {
field: key,
supported: allowed.join(", "),
}));
}
}
Ok(())
}
fn read_bound_i64(spec: &Table, field: &'static str, name: &str) -> mlua::Result<Option<i64>> {
match spec.raw_get::<Value>(field)? {
Value::Nil => Ok(None),
other => Ok(Some(lua_exact_i64(name, other)?)),
}
}
fn read_bound_f64(spec: &Table, field: &'static str, name: &str) -> mlua::Result<Option<f64>> {
match spec.raw_get::<Value>(field)? {
Value::Nil => Ok(None),
other => Ok(Some(lua_to_f64(name, other)?)),
}
}
/// Read `choices` as a raw sequence of Lua strings --- no numeric
/// coercion (unlike `Table::sequence_values::<String>()`, which would
/// silently stringify a numeric entry): a non-string choice is a
/// definition bug, not a value to paper over.
fn read_choices(spec: &Table, name: &str) -> mlua::Result<Vec<String>> {
let Some(t) = spec.raw_get::<Option<Table>>("choices")? else {
return Err(mlua::Error::external(ConfigBindingError::MissingChoices {
name: name.to_owned(),
}));
};
let mut choices = Vec::new();
for v in t.sequence_values::<Value>() {
match v? {
Value::String(s) => choices.push(s.to_str()?.to_owned()),
other => return Err(type_mismatch(name, "enum", &other)),
}
}
Ok(choices)
}
/// Reject spec fields that are meaningless for the declared `type`
/// (review round 1, finding 3).
///
/// `DEFINE_SPEC_FIELDS` whitelists every key for every type, and the
/// kind parser below only reads the ones its own arm cares about ---
/// so without this check `{ type = "string", choices = {"a","b"} }`
/// silently defines a string that accepts anything (the author meant
/// `enum`), and `{ type = "boolean", min = 1 }` silently drops the
/// bound. Both are typo-shaped bugs of exactly the class R50 exists to
/// catch; the whitelist alone only catches misspelled keys, not
/// correctly-spelled keys on the wrong type.
fn check_fields_relevant_to_kind(spec: &Table, type_str: &str) -> mlua::Result<()> {
let numeric = matches!(type_str, "integer" | "number");
for (field, allowed_for) in [
("min", numeric),
("max", numeric),
("choices", type_str == "enum"),
("allow_empty", type_str == "string"),
] {
if !allowed_for && !matches!(spec.raw_get::<Value>(field)?, Value::Nil) {
return Err(mlua::Error::external(
ConfigBindingError::FieldIrrelevantForType {
field,
got_type: type_str.to_owned(),
},
));
}
}
Ok(())
}
fn parse_kind_and_default(spec: &Table, name: &str) -> mlua::Result<(ConfigKind, ConfigValue)> {
let type_str: Option<String> = spec.raw_get("type")?;
let type_str =
type_str.ok_or_else(|| mlua::Error::external(ConfigBindingError::MissingType))?;
check_fields_relevant_to_kind(spec, &type_str)?;
let kind = match type_str.as_str() {
"boolean" => ConfigKind::Boolean,
"integer" => ConfigKind::Integer {
min: read_bound_i64(spec, "min", name)?,
max: read_bound_i64(spec, "max", name)?,
},
"number" => ConfigKind::Number {
min: read_bound_f64(spec, "min", name)?,
max: read_bound_f64(spec, "max", name)?,
},
"string" => ConfigKind::String {
allow_empty: spec
.raw_get::<Option<bool>>("allow_empty")?
.unwrap_or(false),
},
"enum" => ConfigKind::Enum {
choices: read_choices(spec, name)?,
},
other => {
return Err(mlua::Error::external(ConfigBindingError::UnknownType {
got: other.to_owned(),
}));
}
};
let raw_default: Value = spec.raw_get("default")?;
let default = lua_to_config_value(name, &kind, raw_default)?;
Ok((kind, default))
}
fn parse_mutability(spec: &Table) -> mlua::Result<ConfigMutability> {
match spec.raw_get::<Option<String>>("mutability")?.as_deref() {
None | Some("live") => Ok(ConfigMutability::Live),
Some("startup") => Ok(ConfigMutability::StartupOnly),
Some(other) => Err(mlua::Error::external(
ConfigBindingError::UnknownMutability {
got: other.to_owned(),
},
)),
}
}
// ---------------------------------------------------------------------------
// Listener dispatch (Q#CR6)
// ---------------------------------------------------------------------------
/// Snapshot `name`'s listeners, drop the registry borrow, then invoke
/// each in registration order with copied `(new, old, buf)` values.
///
/// The borrow-release is the whole point (Q#CR6): by the time any
/// listener body runs, `reg.borrow()` from *within* that body (e.g. a
/// re-entrant `pmacs.config.set`) sees no outstanding borrow from us.
/// A raising listener is logged to the `*errors*` buffer and does not
/// stop later listeners or affect the already-committed value.
/// [`MAX_DISPATCH_DEPTH`] bounds re-entrant dispatch depth.
fn dispatch_config_listeners(
lua: &Lua,
reg: &SharedConfigRegistry,
name: &str,
change: &ConfigChange,
buf: Option<BufferIdLua>,
) -> mlua::Result<()> {
let listeners = reg.borrow().snapshot(name);
if listeners.is_empty() {
return Ok(());
}
let depth_cell = lua
.app_data_ref::<ConfigDispatchDepth>()
.expect("ConfigDispatchDepth installed by install_config")
.0
.clone();
let depth = depth_cell.get();
if depth >= MAX_DISPATCH_DEPTH {
return Err(mlua::Error::external(ConfigBindingError::ListenerCycle {
max: MAX_DISPATCH_DEPTH,
}));
}
depth_cell.set(depth + 1);
let _guard = DispatchDepthGuard(depth_cell);
let new_lua = config_value_to_lua(lua, &change.new)?;
let old_lua = config_value_to_lua(lua, &change.old)?;
for listener in listeners {
if let Err(err) = listener
.body
.call::<()>((new_lua.clone(), old_lua.clone(), buf))
{
log_config_listener_error(lua, &listener.source, &err);
}
}
Ok(())
}
/// Append a one-line entry to the `*errors*` buffer naming the
/// listener's source. Mirrors `log_hook_error` / `log_buffer_removed_error`
/// in `mod.rs`; a no-op (rather than a panic) if the buffer registry
/// app data isn't installed, matching those precedents.
fn log_config_listener_error(lua: &Lua, source: &SourceLocation, err: &mlua::Error) {
let line = format!(
"[config] on_change listener at {} raised: {err}\n",
source.render()
);
let result = {
let Some(app) = lua.app_data_ref::<SharedRegistry>() else {
return;
};
let mut reg = app.borrow_mut();
let id = match reg.find_by_name(crate::lua::ERRORS_BUFFER_NAME) {
Some(id) => id,
None => reg.create(crate::lua::ERRORS_BUFFER_NAME),
};
let Ok(buf) = reg.get_mut(id) else {
return;
};
let pos = buf.len();
let edit = buf
.apply_edit(EditOp::Insert {
pos,
bytes: line.as_bytes(),
})
.ok();
edit.map(|e| (id, e))
};
if let Some((id, edit)) = result {
super::notify_buffer_edit_to_windows(lua, id, &edit);
}
}
/// If the init phase has completed and the registry isn't frozen yet,
/// freeze it now. Called from `set`/`reset` only (see the module doc's
/// "startup freeze" section) --- `set_local` never consults the frozen
/// flag at all, so freezing ahead of it would change nothing.
fn maybe_freeze_after_init(lua: &Lua, reg: &SharedConfigRegistry) {
let complete = lua
.app_data_ref::<InitCompleteFlag>()
.is_some_and(|f| f.is_complete());
if complete {
let mut r = reg.borrow_mut();
if !r.is_frozen() {
r.freeze();
}
}
}
// ---------------------------------------------------------------------------
// describe() / list()
// ---------------------------------------------------------------------------
/// Build a fresh descriptor table for `name`, shared by `describe` and
/// `list`. Never returns a handle onto registry state (Q#CR3): every
/// field is copied out.
fn describe_one(
lua: &Lua,
reg: &ConfigRegistry,
name: &str,
buf: Option<BufferIdLua>,
) -> mlua::Result<Table> {
let def = reg.get_definition(name).ok_or_else(|| {
mlua::Error::external(ConfigError::NotFound {
name: name.to_owned(),
})
})?;
let t = lua.create_table()?;
t.set("name", def.name.clone())?;
t.set("description", def.description.clone())?;
t.set("type", def.kind.type_name())?;
t.set("default", config_value_to_lua(lua, &def.default)?)?;
match &def.kind {
ConfigKind::Boolean => {}
ConfigKind::Integer { min, max } => {
if let Some(v) = min {
t.set("min", *v)?;
}
if let Some(v) = max {
t.set("max", *v)?;
}
}
ConfigKind::Number { min, max } => {
if let Some(v) = min {
t.set("min", *v)?;
}
if let Some(v) = max {
t.set("max", *v)?;
}
}
ConfigKind::String { allow_empty } => {
t.set("allow_empty", *allow_empty)?;
}
ConfigKind::Enum { choices } => {
let choices_t = lua.create_table_with_capacity(choices.len(), 0)?;
for (i, c) in choices.iter().enumerate() {
choices_t.set(i + 1, c.as_str())?;
}
t.set("choices", choices_t)?;
}
}
t.set(
"mutability",
match def.mutability {
ConfigMutability::Live => "live",
ConfigMutability::StartupOnly => "startup",
},
)?;
// `value` follows the same buf-argument contract as `get` (F9):
// with `buf`, buffer-local -> global -> default; with `None`, the
// global chain only. `global` is always the global-chain
// resolution, independent of `buf`, so a caller can always see
// "what would this be with no override at all on my buffer."
let value = reg
.get(name, buf.map(BufferIdLua::id))
.map_err(mlua::Error::external)?;
t.set("value", config_value_to_lua(lua, value)?)?;
let global = reg.get(name, None).map_err(mlua::Error::external)?;
t.set("global", config_value_to_lua(lua, global)?)?;
// F7: `buffer_local`, never `local` (a Lua keyword). Present only
// when a buffer was given AND that buffer holds an override.
if let Some(b) = buf
&& let Some(v) = reg.local_override(name, b.id())
{
t.set("buffer_local", config_value_to_lua(lua, v)?)?;
}
t.set("source", def.source.render())?;
Ok(t)
}
// ---------------------------------------------------------------------------
// on_change() handle
// ---------------------------------------------------------------------------
/// Userdata handle returned by `pmacs.config.on_change`. Models the
/// `dispose` binding at `mod.rs:1867` (the compile-mode style-overlay
/// handle): explicit, idempotent teardown, no `MetaMethod::Gc` ---
/// `ConfigRegistry::dispose` is itself idempotent and generation-safe
/// by listener id, so this wrapper adds no extra state of its own. A
/// dropped-but-never-disposed handle keeps firing (F3): there is
/// nothing here that would stop it.
struct ConfigListenerHandleLua {
id: u64,
registry: SharedConfigRegistry,
}
impl UserData for ConfigListenerHandleLua {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("dispose", |_, this, ()| {
this.registry.borrow_mut().dispose(this.id);
Ok(())
});
}
}
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
/// Install `pmacs.config.*` over `registry`.
///
/// Called from [`super::install`] while the `pmacs` table is being
/// built, so `pmacs.config` exists before any `builtin/runtime/*.lua`
/// chunk evaluates (Q#CR14) --- the same ordering guarantee
/// `pmacs.command` and `pmacs.hook` already have.
#[allow(
clippy::too_many_lines,
reason = "linear list of raw bindings; splitting fragments a coherent surface"
)]
pub fn install_config(lua: &Lua, registry: &SharedConfigRegistry) -> mlua::Result<Table> {
lua.set_app_data(ConfigDispatchDepth::new());
let config_mod = lua.create_table()?;
{
let reg = registry.clone();
config_mod.set(
"define",
lua.create_function(move |lua, spec: Table| -> mlua::Result<()> {
check_unknown_fields(&spec, DEFINE_SPEC_FIELDS)?;
let name: String = spec.raw_get::<Option<String>>("name")?.unwrap_or_default();
let description: String = spec
.raw_get::<Option<String>>("description")?
.unwrap_or_default();
let (kind, default) = parse_kind_and_default(&spec, &name)?;
let mutability = parse_mutability(&spec)?;
reg.borrow_mut()
.define(
name,
description,
kind,
default,
mutability,
caller_source(lua, 2),
)
.map_err(mlua::Error::external)?;
Ok(())
})?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"get",
lua.create_function(move |lua, (name, buf): (String, Option<BufferIdLua>)| {
let r = reg.borrow();
let v = r
.get(&name, buf.map(BufferIdLua::id))
.map_err(mlua::Error::external)?;
config_value_to_lua(lua, v)
})?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"is_set",
lua.create_function(move |_, (name, buf): (String, Option<BufferIdLua>)| {
reg.borrow()
.is_set(&name, buf.map(BufferIdLua::id))
.map_err(mlua::Error::external)
})?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"set",
lua.create_function(
move |lua, (name, value): (String, Value)| -> mlua::Result<()> {
maybe_freeze_after_init(lua, &reg);
let kind = {
let r = reg.borrow();
r.get_definition(&name)
.ok_or_else(|| {
mlua::Error::external(ConfigError::NotFound { name: name.clone() })
})?
.kind
.clone()
};
let cv = lua_to_config_value(&name, &kind, value)?;
let change = reg
.borrow_mut()
.set(&name, cv)
.map_err(mlua::Error::external)?;
if change.changed {
dispatch_config_listeners(lua, &reg, &name, &change, None)?;
}
Ok(())
},
)?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"set_local",
lua.create_function(
move |lua, (buf, name, value): (BufferIdLua, String, Value)| -> mlua::Result<()> {
let kind = {
let r = reg.borrow();
r.get_definition(&name)
.ok_or_else(|| {
mlua::Error::external(ConfigError::NotFound { name: name.clone() })
})?
.kind
.clone()
};
let cv = lua_to_config_value(&name, &kind, value)?;
let change = reg
.borrow_mut()
.set_local(buf.id(), &name, cv)
.map_err(mlua::Error::external)?;
if change.changed {
dispatch_config_listeners(lua, &reg, &name, &change, Some(buf))?;
}
Ok(())
},
)?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"reset",
lua.create_function(
move |lua, (name, buf): (String, Option<BufferIdLua>)| -> mlua::Result<()> {
maybe_freeze_after_init(lua, &reg);
let change = reg
.borrow_mut()
.reset(&name, buf.map(BufferIdLua::id))
.map_err(mlua::Error::external)?;
if change.changed {
dispatch_config_listeners(lua, &reg, &name, &change, buf)?;
}
Ok(())
},
)?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"describe",
lua.create_function(move |lua, (name, buf): (String, Option<BufferIdLua>)| {
describe_one(lua, &reg.borrow(), &name, buf)
})?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"list",
lua.create_function(move |lua, ()| {
let r = reg.borrow();
let out = lua.create_table_with_capacity(r.names().len(), 0)?;
for (i, name) in r.names().iter().enumerate() {
out.set(i + 1, describe_one(lua, &r, name, None)?)?;
}
Ok(out)
})?,
)?;
}
{
let reg = registry.clone();
config_mod.set(
"on_change",
lua.create_function(move |lua, (name, body): (String, Function)| {
let id = reg
.borrow_mut()
.on_change(&name, body, caller_source(lua, 2))
.map_err(mlua::Error::external)?;
Ok(ConfigListenerHandleLua {
id,
registry: reg.clone(),
})
})?,
)?;
}
Ok(config_mod)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::BufferId;
fn fresh() -> (Lua, SharedConfigRegistry) {
let lua = Lua::new();
let registry: SharedConfigRegistry = Rc::new(RefCell::new(ConfigRegistry::new()));
let config_mod = install_config(&lua, &registry).expect("install_config");
let pmacs = lua.create_table().expect("pmacs table");
pmacs.set("config", config_mod).expect("pmacs.config");
lua.globals().set("pmacs", pmacs).expect("globals");
(lua, registry)
}
/// Execute a chunk purely for side effects; discards any return.
fn run(lua: &Lua, src: &str) -> mlua::Result<()> {
lua.load(src).exec()
}
/// Evaluate a chunk and convert its return value(s) to `T`.
fn eval<T: mlua::FromLuaMulti>(lua: &Lua, src: &str) -> mlua::Result<T> {
lua.load(src).eval::<T>()
}
/// Like [`run`], but under an explicit chunk name --- for
/// exercising `caller_source`'s capture of a "real" file location.
fn run_named(lua: &Lua, name: &str, src: &str) -> mlua::Result<()> {
lua.load(src).set_name(name).exec()
}
// ---- acceptance 1: round-trip every kind, via Lua ----------------------
#[test]
fn define_then_get_round_trips_every_kind_via_lua() {
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="editing.auto-pair", description="d", type="boolean", default=true }
pmacs.config.define{ name="autosave.interval-ms", description="d", type="integer", default=30000, min=1000 }
pmacs.config.define{ name="editing.fill-column", description="d", type="number", default=80.0, min=1.0, max=1000.0 }
pmacs.config.define{ name="editing.comment-prefix", description="d", type="string", default="", allow_empty=true }
pmacs.config.define{ name="editing.line-ending", description="d", type="enum", default="lf", choices={"lf","crlf"} }
"#,
)
.unwrap();
assert!(eval::<bool>(&lua, "return pmacs.config.get('editing.auto-pair')").unwrap());
assert_eq!(
eval::<i64>(&lua, "return pmacs.config.get('autosave.interval-ms')").unwrap(),
30_000
);
assert!(
(eval::<f64>(&lua, "return pmacs.config.get('editing.fill-column')").unwrap() - 80.0)
.abs()
< f64::EPSILON
);
assert_eq!(
eval::<String>(&lua, "return pmacs.config.get('editing.comment-prefix')").unwrap(),
""
);
assert_eq!(
eval::<String>(&lua, "return pmacs.config.get('editing.line-ending')").unwrap(),
"lf"
);
}
// ---- acceptance 2: R50 unknown field + metatable smuggling -------------
#[test]
fn define_rejects_unknown_field_naming_offender_and_supported_keys() {
let (lua, _reg) = fresh();
let err = run(
&lua,
r#"pmacs.config.define{ name="x.y", description="d", type="boolean", default=true, typo_field=1 }"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("typo_field"), "{msg}");
assert!(msg.contains("supported"), "{msg}");
assert!(msg.contains("name"), "{msg}");
}
#[test]
fn define_rejects_missing_or_whitespace_description() {
let (lua, _reg) = fresh();
let err = run(
&lua,
r#"pmacs.config.define{ name="x.y", description=" ", type="boolean", default=true }"#,
)
.unwrap_err();
assert!(err.to_string().contains("R42"), "{err}");
}
#[test]
fn define_ignores_metatable_provided_default_via_raw_access() {
// R50's harder half: a spec table whose OWN keys never include
// `default` at all -- a metatable `__index` answers for it
// instead. Raw access must not pick that up: the define must
// fail as though `default` were absent (nil), not silently
// succeed with the metatable-smuggled value.
let (lua, _reg) = fresh();
let err = run(
&lua,
r#"
local spec = setmetatable({ name="x.y", description="d", type="boolean" }, {
__index = function(_, k) if k == "default" then return true end end,
})
pmacs.config.define(spec)
"#,
)
.unwrap_err();
assert!(
err.to_string().contains("boolean"),
"expected a boolean-vs-nil type mismatch, got: {err}"
);
// And the definition must not have been registered.
let names: i64 = eval(&lua, "return #pmacs.config.list()").unwrap();
assert_eq!(
names, 0,
"the rejected define must not have registered anything"
);
}
// ---- review round 1, finding 3: correctly spelled, wrongly typed --------
#[test]
fn define_rejects_spec_fields_meaningless_for_the_declared_type() {
// The R50 whitelist accepts all nine keys for every type, so
// these are invisible to it: each field below is spelled
// correctly but means nothing for the type it is paired with.
// Before the kind cross-check, `type = "string"` with `choices`
// silently defined a string that accepts ANYTHING -- the author
// plainly meant `enum` -- and `min` on a boolean was dropped.
for (spec, offender) in [
(
r#"{ name="a.b", description="d", type="string", default="x", choices={"a"} }"#,
"choices",
),
(
r#"{ name="a.b", description="d", type="boolean", default=true, min=1 }"#,
"min",
),
(
r#"{ name="a.b", description="d", type="boolean", default=true, max=1 }"#,
"max",
),
(
r#"{ name="a.b", description="d", type="integer", default=1, allow_empty=true }"#,
"allow_empty",
),
(
r#"{ name="a.b", description="d", type="enum", choices={"x"}, default="x", min=1 }"#,
"min",
),
] {
let (lua, _reg) = fresh();
let err = run(&lua, &format!("pmacs.config.define {spec}")).unwrap_err();
assert!(
err.to_string().contains(offender),
"the error must name the misplaced field {offender}: {err}"
);
let n: i64 = eval(&lua, "return #pmacs.config.list()").unwrap();
assert_eq!(n, 0, "a rejected define registers nothing");
}
}
#[test]
fn define_still_accepts_each_field_on_its_own_type() {
// The guard must not over-reject: every field remains legal
// where it belongs, including on `number` as well as `integer`.
let (lua, _reg) = fresh();
for spec in [
r#"{ name="a.int", description="d", type="integer", default=5, min=1, max=9 }"#,
r#"{ name="a.num", description="d", type="number", default=0.5, min=0.0, max=1.0 }"#,
r#"{ name="a.str", description="d", type="string", default="", allow_empty=true }"#,
r#"{ name="a.enum", description="d", type="enum", default="x", choices={"x","y"} }"#,
r#"{ name="a.bool", description="d", type="boolean", default=true }"#,
] {
run(&lua, &format!("pmacs.config.define {spec}"))
.unwrap_or_else(|e| panic!("{spec} must be accepted: {e}"));
}
let n: i64 = eval(&lua, "return #pmacs.config.list()").unwrap();
assert_eq!(n, 5);
}
// ---- review round 1, finding 1: the saturating i64 boundary, via Lua ----
#[test]
fn set_rejects_the_saturating_i64_boundary_from_lua() {
// 2^63 is a float on BOTH backends (LuaJIT has no integer
// subtype; Lua 5.4's `2^63` is a float too). Before the `>=`
// fix this stored i64::MAX -- a silent off-by-one against the
// module's own "never silently change its value" contract.
let (lua, _reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="a.n", description="d", type="integer", default=0 }"#,
)
.unwrap();
let err = run(&lua, "pmacs.config.set('a.n', 2^63)").unwrap_err();
assert!(
err.to_string().contains("integer") || err.to_string().contains("integral"),
"2^63 must be refused, not saturated: {err}"
);
let still: i64 = eval(&lua, "return pmacs.config.get('a.n')").unwrap();
assert_eq!(still, 0, "the refused set must not have stored anything");
}
#[test]
fn define_rejects_the_saturating_i64_boundary_in_a_bound() {
// `min`/`max` go through read_bound_i64 -> lua_exact_i64 ->
// int_from_f64, so the same boundary must be refused in a spec.
let (lua, _reg) = fresh();
let err = run(
&lua,
r#"pmacs.config.define{ name="a.n", description="d", type="integer", default=0, max=2^63 }"#,
)
.unwrap_err();
assert!(
err.to_string().contains("integer") || err.to_string().contains("integral"),
"a 2^63 bound must be refused: {err}"
);
}
// ---- acceptance 9: SourceLocation from the DEFINING module --------------
#[test]
fn source_location_is_captured_from_the_defining_chunk_not_a_helper() {
let (lua, _reg) = fresh();
run_named(
&lua,
"@pmacs/builtin/runtime/pair.lua",
"\n\npmacs.config.define{ name='editing.auto-pair', description='d', type='boolean', default=true }\n",
)
.expect("define from a named chunk");
let source: String = eval(
&lua,
"return pmacs.config.describe('editing.auto-pair').source",
)
.unwrap();
assert!(
source.starts_with("pmacs/builtin/runtime/pair.lua:"),
"expected the defining chunk's own path, got {source:?}"
);
assert!(
source.ends_with(":3"),
"expected line 3 (the call sits after two blank lines), got {source:?}"
);
}
// ---- F9: get(name) with no buffer resolves the global chain only -------
#[test]
fn get_with_no_buffer_ignores_any_local_override() {
let (lua, reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }"#,
)
.unwrap();
let buf = BufferId::next();
reg.borrow_mut()
.set_local(buf, "editing.x", ConfigValue::Bool(false))
.unwrap();
lua.globals().set("__buf", BufferIdLua(buf)).unwrap();
assert!(
eval::<bool>(&lua, "return pmacs.config.get('editing.x')").unwrap(),
"no-buffer get must see the global chain only"
);
assert!(!eval::<bool>(&lua, "return pmacs.config.get('editing.x', __buf)").unwrap());
}
// ---- listeners: the borrow-release bite test ----------------------------
#[test]
fn listener_runs_after_borrow_release_and_can_reentrantly_write() {
// Bite-verified: if `dispatch_config_listeners` still held a
// `RefCell` borrow on the registry while calling the listener
// body, the listener's own `pmacs.config.set` below would hit
// a `BorrowMutError` panic. No panic, plus observing the
// second setting's new value, is the proof the borrow was
// released before Lua ran.
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="a", description="d", type="boolean", default=true }
pmacs.config.define{ name="b", description="d", type="boolean", default=true }
pmacs.config.on_change('a', function(new, old, buf)
pmacs.config.set('b', false)
end)
pmacs.config.set('a', false)
"#,
)
.unwrap();
assert!(
!eval::<bool>(&lua, "return pmacs.config.get('b')").unwrap(),
"the re-entrant set from inside the listener must have committed"
);
}
// ---- Q#CR6 (a)/(b): fires once with buf=nil, not for a shadowed buffer -
#[test]
fn global_set_fires_once_with_nil_buf_and_not_for_a_shadowed_buffer() {
let (lua, reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }"#,
)
.unwrap();
let buf = BufferId::next();
reg.borrow_mut()
.set_local(buf, "editing.x", ConfigValue::Bool(true))
.unwrap();
run(
&lua,
r"
calls = {}
pmacs.config.on_change('editing.x', function(new, old, buf)
table.insert(calls, { new = new, buf = buf })
end)
pmacs.config.set('editing.x', false)
",
)
.unwrap();
assert_eq!(
eval::<i64>(&lua, "return #calls").unwrap(),
1,
"must fire exactly once, not once per buffer"
);
assert!(
eval::<bool>(&lua, "return calls[1].buf == nil").unwrap(),
"global set must report buf = nil"
);
// The shadowed buffer's effective value must not have moved.
assert_eq!(
reg.borrow().get("editing.x", Some(buf)).unwrap(),
&ConfigValue::Bool(true)
);
}
// ---- Q#CR6 (c): remove_buffer purges without firing --------------------
#[test]
fn remove_buffer_purges_locals_without_firing_a_listener() {
let (lua, reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }
fire_count = 0
pmacs.config.on_change('editing.x', function() fire_count = fire_count + 1 end)
"#,
)
.unwrap();
let buf = BufferId::next();
// set_local DOES fire (the buffer's effective value changes) --
// establishes a nonzero baseline so the next assertion is
// meaningful (not just "still zero because nothing happened").
reg.borrow_mut()
.set_local(buf, "editing.x", ConfigValue::Bool(false))
.unwrap();
// set_local was driven directly through the core (not the Lua
// binding above), so it did not dispatch our Lua listener --
// that's fine; this test is about remove_buffer specifically.
let before: i64 = eval(&lua, "return fire_count").unwrap();
reg.borrow_mut().remove_buffer(buf);
let after: i64 = eval(&lua, "return fire_count").unwrap();
assert_eq!(before, after, "remove_buffer must never fire a listener");
}
// ---- Q#CR6 (d): on_change on an undefined name raises NotFound ---------
#[test]
fn on_change_on_undefined_name_raises_not_found() {
let (lua, _reg) = fresh();
let err = run(&lua, "pmacs.config.on_change('nope', function() end)").unwrap_err();
assert!(err.to_string().contains("not defined"), "{err}");
}
// ---- one raising listener is logged and does not block later ones ------
#[test]
fn one_raising_listener_does_not_block_later_listeners_or_the_value() {
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }
second_ran = false
pmacs.config.on_change('editing.x', function() error("boom") end)
pmacs.config.on_change('editing.x', function() second_ran = true end)
pmacs.config.set('editing.x', false)
"#,
)
.unwrap();
assert!(
eval::<bool>(&lua, "return second_ran").unwrap(),
"a raising listener must not block later ones"
);
assert!(
!eval::<bool>(&lua, "return pmacs.config.get('editing.x')").unwrap(),
"the committed value must stay authoritative despite the raise"
);
}
// ---- recursion bound: an accidental listener cycle is stopped ----------
#[test]
fn listener_cycle_hits_the_recursion_bound_instead_of_hanging() {
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="a", description="d", type="integer", default=0 }
pmacs.config.define{ name="b", description="d", type="integer", default=0 }
n = 0
pmacs.config.on_change('a', function(new)
n = n + 1
pmacs.config.set('b', new)
end)
pmacs.config.on_change('b', function(new)
n = n + 1
pmacs.config.set('a', new + 1)
end)
"#,
)
.unwrap();
// The top-level call itself must return successfully -- each
// nested listener failure is logged and absorbed by the level
// above, so the cycle unwinds cleanly rather than raising all
// the way out.
run(&lua, "pmacs.config.set('a', 1)").expect("top-level call must not raise");
let n: i64 = eval(&lua, "return n").unwrap();
assert_eq!(
n,
i64::from(MAX_DISPATCH_DEPTH),
"exactly MAX_DISPATCH_DEPTH listener invocations run before the bound stops the cycle"
);
}
// ---- dispose: idempotent, generation-safe, no GC dependency (F3, 22) ---
#[test]
fn dispose_is_idempotent_and_a_dropped_undisposed_handle_keeps_firing() {
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }
fires = 0
do
local h = pmacs.config.on_change('editing.x', function() fires = fires + 1 end)
h:dispose()
h:dispose() -- idempotent: no error
end
collectgarbage("collect")
pmacs.config.set('editing.x', false)
"#,
)
.unwrap();
assert_eq!(
eval::<i64>(&lua, "return fires").unwrap(),
0,
"disposed listener must not fire"
);
run(
&lua,
r#"
pmacs.config.define{ name="editing.y", description="d", type="boolean", default=true }
fires2 = 0
do
local h2 = pmacs.config.on_change('editing.y', function() fires2 = fires2 + 1 end)
end
collectgarbage("collect")
pmacs.config.set('editing.y', false)
"#,
)
.unwrap();
assert_eq!(
eval::<i64>(&lua, "return fires2").unwrap(),
1,
"a dropped-but-undisposed handle must keep firing (F3)"
);
}
#[test]
fn dispose_is_generation_safe_a_stale_id_never_disposes_a_newer_listener() {
let (lua, reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }"#,
)
.unwrap();
run(
&lua,
r"
first_fires = 0
h1 = pmacs.config.on_change('editing.x', function() first_fires = first_fires + 1 end)
h1:dispose()
second_fires = 0
h2 = pmacs.config.on_change('editing.x', function() second_fires = second_fires + 1 end)
h1:dispose() -- stale: must not touch h2's listener
",
)
.unwrap();
assert_eq!(reg.borrow().snapshot("editing.x").len(), 1);
run(&lua, "pmacs.config.set('editing.x', false)").unwrap();
assert_eq!(eval::<i64>(&lua, "return first_fires").unwrap(), 0);
assert_eq!(eval::<i64>(&lua, "return second_fires").unwrap(), 1);
}
// ---- startup freeze, lazy via InitCompleteFlag --------------------------
#[test]
fn set_after_init_complete_freezes_and_rejects_startup_only_writes() {
let (lua, _reg) = fresh();
let flag = InitCompleteFlag::new();
lua.set_app_data(flag.clone());
run(
&lua,
r#"pmacs.config.define{ name="lsp.root-markers", description="d", type="boolean", default=true, mutability="startup" }"#,
)
.unwrap();
// Before init completes: writable.
run(&lua, "pmacs.config.set('lsp.root-markers', false)").unwrap();
flag.set_complete();
let err = run(&lua, "pmacs.config.set('lsp.root-markers', true)").unwrap_err();
assert!(err.to_string().contains("startup-only"), "{err}");
}
#[test]
fn a_set_local_first_does_not_open_a_startup_only_write_window() {
// Review round 1, finding 2. `set_local` does not call
// `maybe_freeze_after_init`, so the concern was that a
// `set_local` as the first post-init operation defers the freeze
// and lets a subsequent `StartupOnly` write slip through.
//
// It cannot: `set` and `reset` call `maybe_freeze_after_init` as
// their FIRST statement, before the definition lookup and before
// the mutator runs, so the freeze always lands ahead of the
// check in the very same call. This test drives that exact
// ordering -- post-init `set_local` on a Live key, then a
// `StartupOnly` write -- and asserts the write is still refused.
let (lua, reg) = fresh();
let flag = InitCompleteFlag::new();
lua.set_app_data(flag.clone());
run(
&lua,
r#"pmacs.config.define{ name="editing.live-one", description="d", type="boolean", default=true }"#,
)
.unwrap();
run(
&lua,
r#"pmacs.config.define{ name="lsp.root-markers", description="d", type="boolean", default=true, mutability="startup" }"#,
)
.unwrap();
flag.set_complete();
assert!(
!reg.borrow().is_frozen(),
"precondition: nothing has triggered the lazy freeze yet"
);
// The first post-init operation is a set_local on a Live key.
let buf = BufferId::next();
lua.globals().set("__buf", BufferIdLua(buf)).unwrap();
run(
&lua,
"pmacs.config.set_local(__buf, 'editing.live-one', false)",
)
.unwrap();
// The StartupOnly write must still be refused.
let err = run(&lua, "pmacs.config.set('lsp.root-markers', false)").unwrap_err();
assert!(
err.to_string().contains("startup-only"),
"a set_local first must not open a write window: {err}"
);
assert!(
reg.borrow().is_frozen(),
"the set that was refused is itself what triggered the freeze"
);
}
#[test]
fn define_startup_only_always_rejects_set_local() {
let (lua, _reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="lsp.root-markers", description="d", type="boolean", default=true, mutability="startup" }"#,
)
.unwrap();
let buf = BufferId::next();
lua.globals().set("__buf", BufferIdLua(buf)).unwrap();
let err = run(
&lua,
"pmacs.config.set_local(__buf, 'lsp.root-markers', false)",
)
.unwrap_err();
assert!(err.to_string().contains("buffer-local"), "{err}");
}
// ---- describe(): field shape, buffer_local naming (F7), fresh table ----
#[test]
fn describe_has_buffer_local_field_only_when_a_buffer_override_exists() {
let (lua, reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }"#,
)
.unwrap();
let buf = BufferId::next();
lua.globals().set("__buf", BufferIdLua(buf)).unwrap();
assert!(
eval::<bool>(
&lua,
"return pmacs.config.describe('editing.x', __buf).buffer_local == nil"
)
.unwrap(),
"buffer_local must be absent with no override"
);
reg.borrow_mut()
.set_local(buf, "editing.x", ConfigValue::Bool(false))
.unwrap();
assert!(
eval::<bool>(
&lua,
"return pmacs.config.describe('editing.x', __buf).buffer_local == false"
)
.unwrap(),
"buffer_local must reflect the stored override"
);
// Every documented field is present, and describe() returns a
// FRESH table each call (mutating one call's result cannot
// affect the next).
assert!(
eval::<bool>(
&lua,
r#"
local info = pmacs.config.describe('editing.x', __buf)
info.name = "mutated"
local info2 = pmacs.config.describe('editing.x', __buf)
return info2.name == 'editing.x'
and info2.description == 'd'
and info2.type == 'boolean'
and info2.default == true
and info2.mutability == 'live'
and info2.value == false
and info2.global == true
and type(info2.source) == 'string'
"#
)
.unwrap()
);
}
#[test]
fn list_is_stable_and_returns_full_descriptor_tables() {
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="c", description="d", type="boolean", default=true }
pmacs.config.define{ name="a", description="d", type="boolean", default=true }
pmacs.config.define{ name="b", description="d", type="boolean", default=true }
"#,
)
.unwrap();
let check = r"
local l = pmacs.config.list()
return #l == 3 and l[1].name == 'c' and l[2].name == 'a' and l[3].name == 'b'
and l[1].description == 'd'
";
assert!(
eval::<bool>(&lua, check).unwrap(),
"list() must be stable across repeated calls"
);
assert!(eval::<bool>(&lua, check).unwrap(), "and on a second call");
}
// ---- acceptance 6 (generic across luajit/lua54): exactness by value ----
#[test]
fn integer_exactness_is_checked_by_value_not_math_type() {
let (lua, _reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="n", description="d", type="integer", default=0 }"#,
)
.unwrap();
run(&lua, "pmacs.config.set('n', 1500)").unwrap();
assert_eq!(
eval::<i64>(&lua, "return pmacs.config.get('n')").unwrap(),
1500
);
let err = run(&lua, "pmacs.config.set('n', 1500.7)").unwrap_err();
assert!(err.to_string().contains("exact integer"), "{err}");
// A whole-numbered float is accepted exactly (matters most
// under LuaJIT, where this is the ONLY way an integer literal
// ever arrives -- Lua 5.1 has no integer subtype).
run(&lua, "pmacs.config.set('n', 42.0)").unwrap();
assert_eq!(
eval::<i64>(&lua, "return pmacs.config.get('n')").unwrap(),
42
);
// Out-of-i64-range / non-finite floats are rejected too.
let err2 = run(&lua, "pmacs.config.set('n', 1/0)").unwrap_err();
assert!(err2.to_string().contains("finite"), "{err2}");
}
#[test]
fn is_set_reports_override_presence_and_reset_drops_exactly_one_layer() {
let (lua, reg) = fresh();
run(
&lua,
r#"pmacs.config.define{ name="editing.x", description="d", type="boolean", default=true }"#,
)
.unwrap();
let buf = BufferId::next();
lua.globals().set("__buf", BufferIdLua(buf)).unwrap();
assert!(!eval::<bool>(&lua, "return pmacs.config.is_set('editing.x')").unwrap());
run(&lua, "pmacs.config.set('editing.x', true)").unwrap(); // equal to default (F1)
assert!(
eval::<bool>(&lua, "return pmacs.config.is_set('editing.x')").unwrap(),
"an equal-valued override is still stored (F1)"
);
run(&lua, "pmacs.config.set_local(__buf, 'editing.x', false)").unwrap();
assert!(eval::<bool>(&lua, "return pmacs.config.is_set('editing.x', __buf)").unwrap());
run(&lua, "pmacs.config.reset('editing.x', __buf)").unwrap();
assert!(!eval::<bool>(&lua, "return pmacs.config.is_set('editing.x', __buf)").unwrap());
assert!(
eval::<bool>(&lua, "return pmacs.config.is_set('editing.x')").unwrap(),
"reset(name, buf) drops only the local layer"
);
run(&lua, "pmacs.config.reset('editing.x')").unwrap();
assert!(!eval::<bool>(&lua, "return pmacs.config.is_set('editing.x')").unwrap());
drop(reg);
}
// ---- define-before-use (Q#CR10) -----------------------------------------
#[test]
fn ops_on_an_undefined_name_raise_not_found() {
let (lua, _reg) = fresh();
for src in [
"pmacs.config.get('nope')",
"pmacs.config.set('nope', true)",
"pmacs.config.is_set('nope')",
"pmacs.config.describe('nope')",
"pmacs.config.reset('nope')",
] {
let err = run(&lua, src).unwrap_err();
assert!(err.to_string().contains("not defined"), "{src}: {err}");
}
}
// ---- enum / bounds validation surfaces through the Lua boundary --------
#[test]
fn enum_and_bounds_validation_surfaces_through_lua() {
let (lua, _reg) = fresh();
run(
&lua,
r#"
pmacs.config.define{ name="editing.eol", description="d", type="enum", default="lf", choices={"lf","crlf"} }
pmacs.config.define{ name="autosave.interval-ms", description="d", type="integer", default=30000, min=1000 }
"#,
)
.unwrap();
let err = run(&lua, "pmacs.config.set('editing.eol', 'cr')").unwrap_err();
assert!(err.to_string().contains("cr"), "{err}");
let err2 = run(&lua, "pmacs.config.set('autosave.interval-ms', 500)").unwrap_err();
assert!(err2.to_string().contains("range"), "{err2}");
}
}