1664 lines
62 KiB
Rust
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, ®);
|
|
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, ®, &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, ®, &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, ®);
|
|
let change = reg
|
|
.borrow_mut()
|
|
.reset(&name, buf.map(BufferIdLua::id))
|
|
.map_err(mlua::Error::external)?;
|
|
if change.changed {
|
|
dispatch_config_listeners(lua, ®, &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, ®.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, ®istry).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}");
|
|
}
|
|
}
|