2172 lines
76 KiB
Rust
2172 lines
76 KiB
Rust
// config_registry.rs --- Typed, two-scope, introspectable configuration registry.
|
|
|
|
//! Configuration settings.
|
|
//!
|
|
//! Per `docs/config-registry-framing.md` (revision 3), every editor
|
|
//! setting is a named, typed [`ConfigDefinition`] registered in a
|
|
//! [`ConfigRegistry`], mirroring [`crate::command::CommandRegistry`]
|
|
//! and [`crate::hook::HookRegistry`] in shape and error vocabulary:
|
|
//! R42 (mandatory description), duplicate-rejection-over-silent-
|
|
//! overwrite, and a [`SourceLocation`] captured at definition time.
|
|
//! R50 (unknown-field rejection) is enforced by the Lua bindings lane
|
|
//! for this registry, since it is the layer that sees the raw spec
|
|
//! table --- see [`ConfigError::UnknownField`].
|
|
//!
|
|
//! # Scopes
|
|
//!
|
|
//! Exactly two: a global override and, per [`BufferId`], a
|
|
//! buffer-local override. [`ConfigRegistry::get`] resolves
|
|
//! buffer-local -> global -> default when given a buffer, and
|
|
//! global -> default only when given `None` --- there is no ambient
|
|
//! "current buffer" at this layer (Q#CR4, F9).
|
|
//!
|
|
//! # Storage versus change notification (F1)
|
|
//!
|
|
//! These are two different questions, and conflating them breaks the
|
|
//! flagship buffer-local-pin feature:
|
|
//!
|
|
//! * [`ConfigRegistry::set`] and [`ConfigRegistry::set_local`]
|
|
//! **always store** the override, even when it is equal to the
|
|
//! value it shadows.
|
|
//! * The value epoch advances, and a listener dispatch should happen,
|
|
//! **only when the effective value changes** --- reported back via
|
|
//! [`ConfigChange::changed`].
|
|
//!
|
|
//! Concretely: with `editing.auto-pair` globally `true`, pinning one
|
|
//! buffer via `set_local(buf, "editing.auto-pair", true)` must still
|
|
//! record an override for that buffer, so that a later
|
|
//! `set("editing.auto-pair", false)` does not silently flip the
|
|
//! pinned buffer. `equal_valued_local_override_is_still_stored_and_shields_buffer`
|
|
//! in the tests below is written to fail against a "true no-op"
|
|
//! implementation that declines to store the equal-valued override.
|
|
//!
|
|
//! # The value-validation seam
|
|
//!
|
|
//! This module owns *value-level* validation: a [`ConfigValue`]
|
|
//! against a [`ConfigKind`] (type match, bounds, enum membership,
|
|
//! string emptiness, number finiteness). The Lua bindings lane owns
|
|
//! *Lua-level* validation: converting a raw Lua value into a
|
|
//! [`ConfigValue`], strict spec-table parsing, and R50 rejection.
|
|
//! [`ConfigRegistry::validate`] is the seam --- it lets the bindings
|
|
//! lane check a candidate value against an already-registered
|
|
//! definition without committing anything, and [`ConfigRegistry::set`]
|
|
//! / [`ConfigRegistry::set_local`] call the same path internally, so
|
|
//! there is exactly one place the check can drift. For the one
|
|
//! numeric-exactness question that must be identical across both Lua
|
|
//! backends (`LuaJIT` numbers are always `f64`; Lua 5.4 numbers may
|
|
//! carry a native integer subtype), [`ConfigValue::int_from_f64`]
|
|
//! gives the bindings lane a by-value exactness check that never
|
|
//! consults `math.type`.
|
|
//!
|
|
//! # Listeners
|
|
//!
|
|
//! [`ConfigRegistry::on_change`] registers a callback against a
|
|
//! setting name and returns a generation-safe `u64` id;
|
|
//! [`ConfigRegistry::dispose`] removes it, idempotently. This registry
|
|
//! never invokes a listener itself --- [`ConfigRegistry::snapshot`]
|
|
//! hands the caller an owned `Vec<ConfigListener>` so it can drop the
|
|
//! registry borrow before re-entering Lua, exactly as
|
|
//! [`crate::hook::HookRegistry::snapshot`] does. Listeners persist
|
|
//! until explicitly disposed; there is no GC-timed lifetime (Q#CR6,
|
|
//! F3) --- a dropped-but-undisposed handle keeps firing.
|
|
//!
|
|
//! # Mutability and the startup freeze
|
|
//!
|
|
//! A [`ConfigMutability::StartupOnly`] key accepts writes until
|
|
//! [`ConfigRegistry::freeze`] is called (at `set_init_complete` time)
|
|
//! and rejects them after, via [`ConfigError::StartupOnlyAfterFreeze`].
|
|
//! `StartupOnly` and [`ConfigRegistry::set_local`] are mutually
|
|
//! exclusive: a buffer-local write against a `StartupOnly` key is
|
|
//! always rejected with [`ConfigError::StartupOnlyLocal`], independent
|
|
//! of freeze state, because buffer-locals are only ever set at
|
|
//! runtime, long after any freeze (Q#CR10, F5).
|
|
//!
|
|
//! # Buffer-local lifecycle
|
|
//!
|
|
//! [`ConfigRegistry::remove_buffer`] drops a buffer's entire local
|
|
//! map. It fires no listener (Q#CR6 (c)): the buffer is gone, so there
|
|
//! is no effective value left for anyone to observe. Callers that skip
|
|
//! this (a direct buffer-registry removal bypassing the normal
|
|
//! choke point) leak that buffer's locals permanently but harmlessly,
|
|
//! since `BufferId`s are never reused (Q#CR5, F8) --- this module does
|
|
//! not and cannot fix that; it only owns the happy path.
|
|
//!
|
|
//! # Threading
|
|
//!
|
|
//! Single-threaded, like the command / hook registries. Lives behind
|
|
//! `Rc<RefCell<...>>` as Lua app data.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use mlua::Function;
|
|
use thiserror::Error;
|
|
|
|
use crate::buffer::BufferId;
|
|
use crate::command::SourceLocation;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Value vocabulary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The closed value-kind vocabulary (Q#CR3). Deliberately has no
|
|
/// list/table variant --- `string-list` was dropped from stage 1
|
|
/// (framing F6) and table-valued settings are a deferred arc.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum ConfigKind {
|
|
/// A `true`/`false` value.
|
|
Boolean,
|
|
/// A signed 64-bit integer, with optional inclusive bounds.
|
|
Integer {
|
|
/// Inclusive lower bound, if any.
|
|
min: Option<i64>,
|
|
/// Inclusive upper bound, if any.
|
|
max: Option<i64>,
|
|
},
|
|
/// A finite `f64`, with optional inclusive bounds. Bounds must
|
|
/// themselves be finite (checked at `define` time).
|
|
Number {
|
|
/// Inclusive lower bound, if any.
|
|
min: Option<f64>,
|
|
/// Inclusive upper bound, if any.
|
|
max: Option<f64>,
|
|
},
|
|
/// A UTF-8 string.
|
|
String {
|
|
/// Whether an empty string is a valid value.
|
|
allow_empty: bool,
|
|
},
|
|
/// A closed set of string choices. Values are stored as
|
|
/// [`ConfigValue::Str`] and validated against `choices`.
|
|
Enum {
|
|
/// The valid choices. Must be non-empty and duplicate-free
|
|
/// (checked at `define` time).
|
|
choices: Vec<String>,
|
|
},
|
|
}
|
|
|
|
impl ConfigKind {
|
|
/// Stable type-name string used in error messages. Matches
|
|
/// [`ConfigValue::type_name`]'s vocabulary except for `Enum`,
|
|
/// whose values are physically strings but whose *kind* is more
|
|
/// useful to name than its storage representation.
|
|
#[must_use]
|
|
pub fn type_name(&self) -> &'static str {
|
|
match self {
|
|
Self::Boolean => "boolean",
|
|
Self::Integer { .. } => "integer",
|
|
Self::Number { .. } => "number",
|
|
Self::String { .. } => "string",
|
|
Self::Enum { .. } => "enum",
|
|
}
|
|
}
|
|
|
|
/// Validate that the kind's own constraints are internally
|
|
/// consistent, independent of any candidate value: finite bounds,
|
|
/// `min <= max`, and (for `Enum`) duplicate-free choices. Called
|
|
/// once at `define` time, before the default is checked against
|
|
/// the kind.
|
|
fn validate_self(&self, name: &str) -> Result<(), ConfigError> {
|
|
match self {
|
|
Self::Boolean | Self::String { .. } => Ok(()),
|
|
Self::Integer { min, max } => {
|
|
if let (Some(lo), Some(hi)) = (min, max)
|
|
&& lo > hi
|
|
{
|
|
return Err(ConfigError::OutOfRange {
|
|
name: name.to_owned(),
|
|
detail: format!("minimum {lo} exceeds maximum {hi}"),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
Self::Number { min, max } => {
|
|
for bound in [*min, *max].into_iter().flatten() {
|
|
if !bound.is_finite() {
|
|
return Err(ConfigError::NonFiniteNumber {
|
|
name: name.to_owned(),
|
|
value: bound,
|
|
});
|
|
}
|
|
}
|
|
if let (Some(lo), Some(hi)) = (min, max)
|
|
&& lo > hi
|
|
{
|
|
return Err(ConfigError::OutOfRange {
|
|
name: name.to_owned(),
|
|
detail: format!("minimum {lo} exceeds maximum {hi}"),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
Self::Enum { choices } => {
|
|
// Rejected directly rather than left to the default's
|
|
// own `NotAChoice` failure: an empty choice list is a
|
|
// malformed *definition*, and reporting it as "the
|
|
// default is not one of []" sends the author looking at
|
|
// the wrong field.
|
|
if choices.is_empty() {
|
|
return Err(ConfigError::EmptyChoices {
|
|
name: name.to_owned(),
|
|
});
|
|
}
|
|
let mut seen = HashSet::new();
|
|
for choice in choices {
|
|
if !seen.insert(choice.as_str()) {
|
|
return Err(ConfigError::DuplicateChoice {
|
|
name: name.to_owned(),
|
|
choice: choice.clone(),
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate `value` against this kind: type match, numeric bounds,
|
|
/// enum membership, string emptiness, and number finiteness. Pure
|
|
/// value-vs-kind logic with no knowledge of scope --- resolving
|
|
/// which layer a value lives in is [`ConfigRegistry`]'s job. This
|
|
/// is the half of validation this module owns (see the module
|
|
/// doc's "value-validation seam" section); the Lua bindings lane
|
|
/// calls it (indirectly, via [`ConfigRegistry::validate`]) after
|
|
/// converting a raw Lua value into a [`ConfigValue`].
|
|
pub fn validate(&self, name: &str, value: &ConfigValue) -> Result<(), ConfigError> {
|
|
match (self, value) {
|
|
(Self::Boolean, ConfigValue::Bool(_)) => Ok(()),
|
|
(Self::Integer { min, max }, ConfigValue::Int(v)) => {
|
|
if let Some(lo) = min
|
|
&& v < lo
|
|
{
|
|
return Err(ConfigError::OutOfRange {
|
|
name: name.to_owned(),
|
|
detail: format!("{v} is below the minimum {lo}"),
|
|
});
|
|
}
|
|
if let Some(hi) = max
|
|
&& v > hi
|
|
{
|
|
return Err(ConfigError::OutOfRange {
|
|
name: name.to_owned(),
|
|
detail: format!("{v} is above the maximum {hi}"),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
(Self::Number { min, max }, ConfigValue::Num(v)) => {
|
|
if !v.is_finite() {
|
|
return Err(ConfigError::NonFiniteNumber {
|
|
name: name.to_owned(),
|
|
value: *v,
|
|
});
|
|
}
|
|
if let Some(lo) = min
|
|
&& v < lo
|
|
{
|
|
return Err(ConfigError::OutOfRange {
|
|
name: name.to_owned(),
|
|
detail: format!("{v} is below the minimum {lo}"),
|
|
});
|
|
}
|
|
if let Some(hi) = max
|
|
&& v > hi
|
|
{
|
|
return Err(ConfigError::OutOfRange {
|
|
name: name.to_owned(),
|
|
detail: format!("{v} is above the maximum {hi}"),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
(Self::String { allow_empty }, ConfigValue::Str(s)) => {
|
|
if !allow_empty && s.is_empty() {
|
|
return Err(ConfigError::EmptyString {
|
|
name: name.to_owned(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
(Self::Enum { choices }, ConfigValue::Str(s)) => {
|
|
if choices.iter().any(|c| c == s) {
|
|
Ok(())
|
|
} else {
|
|
Err(ConfigError::NotAChoice {
|
|
name: name.to_owned(),
|
|
got: s.clone(),
|
|
choices: choices.clone(),
|
|
})
|
|
}
|
|
}
|
|
_ => Err(ConfigError::TypeMismatch {
|
|
name: name.to_owned(),
|
|
expected: self.type_name(),
|
|
got: value.type_name(),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An owned configuration value. Lua tables, functions and userdata
|
|
/// are never stored --- only these four scalars (Q#CR3).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum ConfigValue {
|
|
/// A boolean value.
|
|
Bool(bool),
|
|
/// A signed 64-bit integer value.
|
|
Int(i64),
|
|
/// A finite `f64` value.
|
|
Num(f64),
|
|
/// A string value. Also used for `Enum`-kind values.
|
|
Str(String),
|
|
}
|
|
|
|
impl ConfigValue {
|
|
/// Stable type-name string used in error messages. Matches
|
|
/// [`ConfigKind::type_name`]'s vocabulary, except an `Enum`
|
|
/// definition's values report as `"string"` here since that is
|
|
/// their physical representation.
|
|
#[must_use]
|
|
pub fn type_name(&self) -> &'static str {
|
|
match self {
|
|
Self::Bool(_) => "boolean",
|
|
Self::Int(_) => "integer",
|
|
Self::Num(_) => "number",
|
|
Self::Str(_) => "string",
|
|
}
|
|
}
|
|
|
|
/// Construct a [`Self::Int`] from an `f64`, checking exactness
|
|
/// **by value**. This is the seam the Lua bindings lane needs for
|
|
/// cross-backend-identical integer handling: `LuaJIT` numbers are
|
|
/// always `f64` (Lua 5.1 has no integer subtype), Lua 5.4 numbers
|
|
/// may carry a native integer subtype --- and this function never
|
|
/// looks at which backend produced `v` or what `math.type` would
|
|
/// say, only at the numeric value itself, so both backends agree
|
|
/// byte-for-byte.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NonFiniteNumber`] if `v` is `NaN` or infinite;
|
|
/// [`ConfigError::NonIntegral`] if `v` has a fractional part or
|
|
/// falls outside the range exactly representable as an `i64`.
|
|
pub fn int_from_f64(name: &str, v: f64) -> Result<Self, ConfigError> {
|
|
if !v.is_finite() {
|
|
return Err(ConfigError::NonFiniteNumber {
|
|
name: name.to_owned(),
|
|
value: v,
|
|
});
|
|
}
|
|
// The bounds are deliberately ASYMMETRIC. `i64::MIN as f64` is
|
|
// -2^63, which round-trips exactly, so `<` correctly admits it.
|
|
// `i64::MAX as f64` rounds UP to 2^63 --- one more than
|
|
// `i64::MAX` --- so a `>` here would admit exactly 2^63 and then
|
|
// `v as i64` would saturate it to `i64::MAX`, silently storing a
|
|
// different number than the caller wrote. `>=` rejects it.
|
|
if v.fract() != 0.0 || v < i64::MIN as f64 || v >= i64::MAX as f64 {
|
|
return Err(ConfigError::NonIntegral {
|
|
name: name.to_owned(),
|
|
value: v,
|
|
});
|
|
}
|
|
Ok(Self::Int(v as i64))
|
|
}
|
|
}
|
|
|
|
/// When a definition's value may be written (Q#CR10).
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum ConfigMutability {
|
|
/// Writable at any time, including after the startup freeze.
|
|
Live,
|
|
/// Writable only while user config is loading. A write after
|
|
/// [`ConfigRegistry::freeze`] returns
|
|
/// [`ConfigError::StartupOnlyAfterFreeze`]. Mutually exclusive
|
|
/// with [`ConfigRegistry::set_local`], which always returns
|
|
/// [`ConfigError::StartupOnlyLocal`] for a key of this mutability
|
|
/// (F5): buffer-locals are only ever set at runtime, long after
|
|
/// any freeze.
|
|
StartupOnly,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Definitions and listeners
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A registered configuration setting.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ConfigDefinition {
|
|
/// Unique, dotted, kebab-cased name (e.g. `editing.auto-pair`).
|
|
pub name: String,
|
|
/// One-line human-readable description (R42, mandatory,
|
|
/// non-empty after trim).
|
|
pub description: String,
|
|
/// The value's type and constraints.
|
|
pub kind: ConfigKind,
|
|
/// The value used when no override applies at any scope.
|
|
pub default: ConfigValue,
|
|
/// When the value may be written.
|
|
pub mutability: ConfigMutability,
|
|
/// Where the call to `pmacs.config.define` originated.
|
|
pub source: SourceLocation,
|
|
}
|
|
|
|
/// One callback registered against a setting name via
|
|
/// [`ConfigRegistry::on_change`].
|
|
///
|
|
/// Cloning is cheap: `String`s clone trivially and `mlua::Function` is
|
|
/// reference-counted internally.
|
|
#[derive(Clone)]
|
|
pub struct ConfigListener {
|
|
/// Generation-safe id, never reused. Returned by `on_change` and
|
|
/// consumed by [`ConfigRegistry::dispose`].
|
|
pub id: u64,
|
|
/// The setting name this listener watches.
|
|
pub name: String,
|
|
/// The Lua callback body, invoked as `function(new, old, buf)`.
|
|
pub body: Function,
|
|
/// Where the call to `pmacs.config.on_change` originated.
|
|
pub source: SourceLocation,
|
|
}
|
|
|
|
/// Outcome of a [`ConfigRegistry::set`], [`ConfigRegistry::set_local`]
|
|
/// or [`ConfigRegistry::reset`] call: the effective value immediately
|
|
/// before and after, and whether it actually changed (F1). The
|
|
/// override itself is always stored (or, for `reset`, dropped)
|
|
/// regardless of `changed` --- only epoch advancement and listener
|
|
/// dispatch key on it. A caller dispatching `on_change` uses `new`/
|
|
/// `old` directly as the listener's `(new, old, buf)` arguments.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ConfigChange {
|
|
/// `true` iff the effective value differs from before the call.
|
|
pub changed: bool,
|
|
/// The effective value immediately before this call.
|
|
pub old: ConfigValue,
|
|
/// The effective value immediately after this call. Equal to
|
|
/// `old` when `changed` is `false`.
|
|
pub new: ConfigValue,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Errors raised by the config registry.
|
|
#[derive(Debug, Error)]
|
|
pub enum ConfigError {
|
|
/// `pmacs.config.define` was called with no name.
|
|
#[error("config name must be non-empty")]
|
|
EmptyName,
|
|
|
|
/// Q#CR9: the name failed the dotted, kebab-cased grammar
|
|
/// (`[a-z][a-z0-9]*(-[a-z0-9]+)*` per dot-separated segment) or
|
|
/// exceeded the 128-byte length bound.
|
|
#[error("config name \"{name}\" is invalid: {reason}")]
|
|
InvalidName {
|
|
/// The offending name.
|
|
name: String,
|
|
/// Which grammar rule it broke.
|
|
reason: String,
|
|
},
|
|
|
|
/// R42: `define` was called without a description, or with one
|
|
/// that's empty after trimming.
|
|
#[error("config \"{name}\" requires a non-empty description (R42)")]
|
|
MissingDescription {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// A config with this name is already defined with a different
|
|
/// specification. A byte-for-byte identical redefinition is
|
|
/// idempotent and does **not** raise this (Q#CR10).
|
|
#[error(
|
|
"config \"{name}\" is already defined with a different specification (refusing to redefine)"
|
|
)]
|
|
DuplicateName {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// `get`/`set`/`set_local`/`reset`/`is_set`/`on_change` referenced
|
|
/// a name that has not been `define`d (Q#CR10: define-before-use).
|
|
#[error("config \"{name}\" is not defined")]
|
|
NotFound {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// A value's runtime type does not match its definition's
|
|
/// [`ConfigKind`].
|
|
#[error("config \"{name}\" expects a {expected} value, got {got}")]
|
|
TypeMismatch {
|
|
/// The offending config name.
|
|
name: String,
|
|
/// The kind's declared type name.
|
|
expected: &'static str,
|
|
/// The candidate value's type name.
|
|
got: &'static str,
|
|
},
|
|
|
|
/// An integer or number value fell outside its definition's
|
|
/// declared `min`/`max`, or the bounds themselves are inverted
|
|
/// (`min > max`) at define time.
|
|
#[error("config \"{name}\" is out of range: {detail}")]
|
|
OutOfRange {
|
|
/// The offending config name.
|
|
name: String,
|
|
/// A human-readable description of which bound was broken.
|
|
detail: String,
|
|
},
|
|
|
|
/// An enum-kind value is not one of its definition's `choices`.
|
|
#[error("config \"{name}\" value \"{got}\" is not one of: {choices:?}")]
|
|
NotAChoice {
|
|
/// The offending config name.
|
|
name: String,
|
|
/// The rejected value.
|
|
got: String,
|
|
/// The definition's full choice list.
|
|
choices: Vec<String>,
|
|
},
|
|
|
|
/// An enum-kind definition listed the same choice twice. Not part
|
|
/// of the caller-given error vocabulary; added because the
|
|
/// framing's acceptance list (item 2) explicitly requires
|
|
/// rejecting this at define time and none of the other variants
|
|
/// name it precisely.
|
|
#[error("config \"{name}\" enum choices contain a duplicate: \"{choice}\"")]
|
|
DuplicateChoice {
|
|
/// The offending config name.
|
|
name: String,
|
|
/// The choice that appeared more than once.
|
|
choice: String,
|
|
},
|
|
|
|
/// An enum-kind definition listed no choices at all. Rejected
|
|
/// directly so the author is pointed at `choices`, rather than
|
|
/// indirectly via the default failing `NotAChoice` against an empty
|
|
/// list.
|
|
#[error("config \"{name}\" is an enum with no choices; `choices` must be non-empty")]
|
|
EmptyChoices {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// A string-kind value was empty and the definition's
|
|
/// `allow_empty` is `false`.
|
|
#[error("config \"{name}\" does not allow an empty string")]
|
|
EmptyString {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// A number-kind value, or a number-kind bound, was not finite
|
|
/// (`NaN` or infinite).
|
|
#[error("config \"{name}\" requires a finite number, got {value}")]
|
|
NonFiniteNumber {
|
|
/// The offending config name.
|
|
name: String,
|
|
/// The non-finite value.
|
|
value: f64,
|
|
},
|
|
|
|
/// An integer-kind candidate did not represent an exact integer
|
|
/// **by value** --- see [`ConfigValue::int_from_f64`].
|
|
#[error("config \"{name}\" requires an exact integer, got {value}")]
|
|
NonIntegral {
|
|
/// The offending config name.
|
|
name: String,
|
|
/// The non-integral (or out-of-`i64`-range) candidate.
|
|
value: f64,
|
|
},
|
|
|
|
/// Q#CR10/F5: `set_local` targeted a
|
|
/// [`ConfigMutability::StartupOnly`] definition. Always rejected,
|
|
/// independent of freeze state.
|
|
#[error("config \"{name}\" is startup-only and cannot carry a buffer-local override")]
|
|
StartupOnlyLocal {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// Q#CR10: `set`/`reset` targeted a
|
|
/// [`ConfigMutability::StartupOnly`] definition after
|
|
/// [`ConfigRegistry::freeze`] was called.
|
|
#[error("config \"{name}\" is startup-only and cannot be written after startup completes")]
|
|
StartupOnlyAfterFreeze {
|
|
/// The offending config name.
|
|
name: String,
|
|
},
|
|
|
|
/// R50: the Lua bindings lane found a key in a raw spec table that
|
|
/// this registry doesn't know about (or the key's value came from
|
|
/// a metatable rather than the table itself). Raised by the
|
|
/// bindings lane, not by this module directly --- `supported` is
|
|
/// the lane's own field list, pre-joined into the message the way
|
|
/// [`crate::hook::HookError::UnknownField`] hardcodes its own.
|
|
#[error("unknown field `{field}` in config spec; supported: {supported}")]
|
|
UnknownField {
|
|
/// The offending key.
|
|
field: String,
|
|
/// The bindings lane's supported-field list.
|
|
supported: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Name grammar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Maximum byte length of a config name (Q#CR9).
|
|
const MAX_NAME_LEN: usize = 128;
|
|
|
|
/// Validate the dotted, kebab-cased name grammar (Q#CR9): each
|
|
/// dot-separated segment matches `[a-z][a-z0-9]*(-[a-z0-9]+)*`, ASCII
|
|
/// only, at most [`MAX_NAME_LEN`] bytes total. Deliberately rejects a
|
|
/// trailing hyphen, a doubled hyphen, an empty segment, and a leading
|
|
/// digit.
|
|
fn validate_name(name: &str) -> Result<(), ConfigError> {
|
|
if name.is_empty() {
|
|
return Err(ConfigError::EmptyName);
|
|
}
|
|
if name.len() > MAX_NAME_LEN {
|
|
return Err(ConfigError::InvalidName {
|
|
name: name.to_owned(),
|
|
reason: format!("exceeds the {MAX_NAME_LEN}-byte length limit"),
|
|
});
|
|
}
|
|
for segment in name.split('.') {
|
|
if let Err(reason) = validate_segment(segment) {
|
|
return Err(ConfigError::InvalidName {
|
|
name: name.to_owned(),
|
|
reason: reason.to_owned(),
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate one dot-separated segment against
|
|
/// `[a-z][a-z0-9]*(-[a-z0-9]+)*`.
|
|
fn validate_segment(segment: &str) -> Result<(), &'static str> {
|
|
let mut chars = segment.chars();
|
|
let Some(first) = chars.next() else {
|
|
return Err("contains an empty segment");
|
|
};
|
|
if !first.is_ascii_lowercase() {
|
|
return Err("each segment must start with a lowercase letter");
|
|
}
|
|
let mut prev_hyphen = false;
|
|
let mut trailing_hyphen = false;
|
|
for c in chars {
|
|
if c == '-' {
|
|
if prev_hyphen {
|
|
return Err("contains a doubled hyphen");
|
|
}
|
|
prev_hyphen = true;
|
|
trailing_hyphen = true;
|
|
} else if c.is_ascii_lowercase() || c.is_ascii_digit() {
|
|
prev_hyphen = false;
|
|
trailing_hyphen = false;
|
|
} else {
|
|
return Err("contains a character outside a-z, 0-9, and hyphen");
|
|
}
|
|
}
|
|
if trailing_hyphen {
|
|
return Err("ends with a trailing hyphen");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigRegistry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Registry of named, typed configuration settings with global and
|
|
/// buffer-local override scopes.
|
|
///
|
|
/// Construction goes through [`Self::new`] / [`Self::default`]. Define
|
|
/// via [`Self::define`]; read via [`Self::get`]; write via
|
|
/// [`Self::set`] / [`Self::set_local`]; drop an override via
|
|
/// [`Self::reset`]. See the module doc for the storage-versus-epoch
|
|
/// split (F1) and the resolution rules (F9).
|
|
#[derive(Default)]
|
|
pub struct ConfigRegistry {
|
|
by_name: HashMap<String, ConfigDefinition>,
|
|
/// Definition order, for stable listing.
|
|
order: Vec<String>,
|
|
/// Global overrides only. Absence means "fall through to
|
|
/// default", not "value is the default".
|
|
global: HashMap<String, ConfigValue>,
|
|
/// Per-buffer overrides only, keyed the same way as `global`.
|
|
locals: HashMap<BufferId, HashMap<String, ConfigValue>>,
|
|
/// Registered `on_change` listeners, in registration order.
|
|
listeners: Vec<ConfigListener>,
|
|
next_listener_id: u64,
|
|
frozen: bool,
|
|
definition_epoch: u64,
|
|
value_epoch: u64,
|
|
}
|
|
|
|
impl ConfigRegistry {
|
|
/// An empty registry.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
// -- definitions ---------------------------------------------------
|
|
|
|
/// Define a new setting.
|
|
///
|
|
/// Validates, in order: the name grammar (Q#CR9), R42 (non-empty
|
|
/// description), the kind's internal consistency (finite bounds,
|
|
/// `min <= max`, duplicate-free enum choices), and the default
|
|
/// against its own kind. A byte-for-byte identical redefinition
|
|
/// (same description, kind, default and mutability) succeeds as a
|
|
/// no-op, supporting idempotent config reload; a conflicting
|
|
/// redefinition fails with [`ConfigError::DuplicateName`] and
|
|
/// leaves the original definition --- including its overrides and
|
|
/// its source location --- exactly as it was.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// See the variants above; nothing is mutated and neither epoch
|
|
/// advances on any error path.
|
|
pub fn define(
|
|
&mut self,
|
|
name: String,
|
|
description: String,
|
|
kind: ConfigKind,
|
|
default: ConfigValue,
|
|
mutability: ConfigMutability,
|
|
source: SourceLocation,
|
|
) -> Result<(), ConfigError> {
|
|
validate_name(&name)?;
|
|
if description.trim().is_empty() {
|
|
return Err(ConfigError::MissingDescription { name });
|
|
}
|
|
kind.validate_self(&name)?;
|
|
kind.validate(&name, &default)?;
|
|
|
|
if let Some(existing) = self.by_name.get(&name) {
|
|
if existing.description == description
|
|
&& existing.kind == kind
|
|
&& existing.default == default
|
|
&& existing.mutability == mutability
|
|
{
|
|
return Ok(());
|
|
}
|
|
return Err(ConfigError::DuplicateName { name });
|
|
}
|
|
|
|
self.order.push(name.clone());
|
|
self.by_name.insert(
|
|
name.clone(),
|
|
ConfigDefinition {
|
|
name,
|
|
description,
|
|
kind,
|
|
default,
|
|
mutability,
|
|
source,
|
|
},
|
|
);
|
|
self.definition_epoch = self.definition_epoch.saturating_add(1);
|
|
Ok(())
|
|
}
|
|
|
|
fn definition(&self, name: &str) -> Result<&ConfigDefinition, ConfigError> {
|
|
self.by_name.get(name).ok_or_else(|| ConfigError::NotFound {
|
|
name: name.to_owned(),
|
|
})
|
|
}
|
|
|
|
/// Look up a definition by name.
|
|
#[must_use]
|
|
pub fn get_definition(&self, name: &str) -> Option<&ConfigDefinition> {
|
|
self.by_name.get(name)
|
|
}
|
|
|
|
/// True iff `name` is defined.
|
|
#[must_use]
|
|
pub fn contains(&self, name: &str) -> bool {
|
|
self.by_name.contains_key(name)
|
|
}
|
|
|
|
/// Names in definition order.
|
|
#[must_use]
|
|
pub fn names(&self) -> &[String] {
|
|
&self.order
|
|
}
|
|
|
|
/// Number of defined settings.
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.by_name.len()
|
|
}
|
|
|
|
/// True iff no settings are defined.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.by_name.is_empty()
|
|
}
|
|
|
|
/// Validate `value` against `name`'s definition without storing
|
|
/// anything. The seam the Lua bindings lane uses once it has
|
|
/// converted a raw Lua value into a [`ConfigValue`]: call this to
|
|
/// get a properly-vocabularied [`ConfigError`] before deciding
|
|
/// whether to call [`Self::set`] / [`Self::set_local`] --- though
|
|
/// calling `set`/`set_local` directly is also fine, since they run
|
|
/// the identical check internally.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined; otherwise
|
|
/// whatever [`ConfigKind::validate`] returns.
|
|
pub fn validate(&self, name: &str, value: &ConfigValue) -> Result<(), ConfigError> {
|
|
let def = self.definition(name)?;
|
|
def.kind.validate(name, value)
|
|
}
|
|
|
|
// -- resolution ------------------------------------------------------
|
|
|
|
/// Resolve the effective value of `name`.
|
|
///
|
|
/// With `buf`, resolution is buffer-local -> global -> default.
|
|
/// With `None`, resolution is global -> default **only** --- there
|
|
/// is no ambient "current buffer" at this layer (Q#CR4, F9): a
|
|
/// caller that wants buffer-aware behavior must pass the buffer.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined.
|
|
pub fn get(&self, name: &str, buf: Option<BufferId>) -> Result<&ConfigValue, ConfigError> {
|
|
let def = self.definition(name)?;
|
|
if let Some(id) = buf
|
|
&& let Some(v) = self.locals.get(&id).and_then(|m| m.get(name))
|
|
{
|
|
return Ok(v);
|
|
}
|
|
Ok(self.global.get(name).unwrap_or(&def.default))
|
|
}
|
|
|
|
/// True iff an override is present for `name` at the queried
|
|
/// layer: the buffer-local layer if `buf` is given, the global
|
|
/// layer otherwise. Well-defined precisely because overrides are
|
|
/// always stored (F1) --- including one equal to the value it
|
|
/// shadows.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined.
|
|
pub fn is_set(&self, name: &str, buf: Option<BufferId>) -> Result<bool, ConfigError> {
|
|
self.definition(name)?;
|
|
Ok(match buf {
|
|
Some(id) => self.locals.get(&id).is_some_and(|m| m.contains_key(name)),
|
|
None => self.global.contains_key(name),
|
|
})
|
|
}
|
|
|
|
/// The global override for `name`, if one is stored. `None` means
|
|
/// "falls through to default", distinct from an override that
|
|
/// happens to equal the default.
|
|
#[must_use]
|
|
pub fn global_override(&self, name: &str) -> Option<&ConfigValue> {
|
|
self.global.get(name)
|
|
}
|
|
|
|
/// The buffer-local override for `name` on `buf`, if one is
|
|
/// stored.
|
|
#[must_use]
|
|
pub fn local_override(&self, name: &str, buf: BufferId) -> Option<&ConfigValue> {
|
|
self.locals.get(&buf).and_then(|m| m.get(name))
|
|
}
|
|
|
|
// -- writes ------------------------------------------------------------
|
|
|
|
/// Set the global override for `name`.
|
|
///
|
|
/// The override is stored unconditionally, even if `value` equals
|
|
/// the value it shadows (F1). The returned [`ConfigChange`] tells
|
|
/// the caller whether the *effective* global value actually
|
|
/// changed; the value epoch advances iff it did.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined;
|
|
/// [`ConfigError::StartupOnlyAfterFreeze`] if the definition is
|
|
/// [`ConfigMutability::StartupOnly`] and [`Self::freeze`] has
|
|
/// already been called; otherwise whatever [`Self::validate`]
|
|
/// returns for `value`.
|
|
pub fn set(&mut self, name: &str, value: ConfigValue) -> Result<ConfigChange, ConfigError> {
|
|
let (mutability, default) = {
|
|
let def = self.definition(name)?;
|
|
(def.mutability, def.default.clone())
|
|
};
|
|
if mutability == ConfigMutability::StartupOnly && self.frozen {
|
|
return Err(ConfigError::StartupOnlyAfterFreeze {
|
|
name: name.to_owned(),
|
|
});
|
|
}
|
|
self.validate(name, &value)?;
|
|
|
|
let old = self.global.get(name).cloned().unwrap_or(default);
|
|
let new = value.clone();
|
|
let changed = old != new;
|
|
self.global.insert(name.to_owned(), value);
|
|
if changed {
|
|
self.value_epoch = self.value_epoch.saturating_add(1);
|
|
}
|
|
Ok(ConfigChange { changed, old, new })
|
|
}
|
|
|
|
/// Set the buffer-local override for `name` on `buf`.
|
|
///
|
|
/// The override is stored unconditionally, even if `value` equals
|
|
/// the value it shadows --- this is the storage half of F1's fix:
|
|
/// a buffer pinned to the current global value must stay pinned
|
|
/// when the global value later changes. The returned
|
|
/// [`ConfigChange`] reports whether `buf`'s effective value
|
|
/// changed; the value epoch advances iff it did.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined;
|
|
/// [`ConfigError::StartupOnlyLocal`] if the definition is
|
|
/// [`ConfigMutability::StartupOnly`] (always, independent of
|
|
/// freeze state); otherwise whatever [`Self::validate`] returns
|
|
/// for `value`.
|
|
pub fn set_local(
|
|
&mut self,
|
|
buf: BufferId,
|
|
name: &str,
|
|
value: ConfigValue,
|
|
) -> Result<ConfigChange, ConfigError> {
|
|
let (mutability, default) = {
|
|
let def = self.definition(name)?;
|
|
(def.mutability, def.default.clone())
|
|
};
|
|
if mutability == ConfigMutability::StartupOnly {
|
|
return Err(ConfigError::StartupOnlyLocal {
|
|
name: name.to_owned(),
|
|
});
|
|
}
|
|
self.validate(name, &value)?;
|
|
|
|
let global_effective = self.global.get(name).cloned().unwrap_or(default);
|
|
let old = self
|
|
.locals
|
|
.get(&buf)
|
|
.and_then(|m| m.get(name))
|
|
.cloned()
|
|
.unwrap_or(global_effective);
|
|
let new = value.clone();
|
|
let changed = old != new;
|
|
self.locals
|
|
.entry(buf)
|
|
.or_default()
|
|
.insert(name.to_owned(), value);
|
|
if changed {
|
|
self.value_epoch = self.value_epoch.saturating_add(1);
|
|
}
|
|
Ok(ConfigChange { changed, old, new })
|
|
}
|
|
|
|
/// Drop exactly one override layer for `name`.
|
|
///
|
|
/// With `buf`, drops only that buffer's local override and
|
|
/// re-exposes the global chain. With `None`, drops the global
|
|
/// override and re-exposes the default. The value epoch advances
|
|
/// iff the effective value actually changes as a result.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined;
|
|
/// [`ConfigError::StartupOnlyAfterFreeze`] if resetting the
|
|
/// *global* layer of a [`ConfigMutability::StartupOnly`]
|
|
/// definition after [`Self::freeze`].
|
|
pub fn reset(
|
|
&mut self,
|
|
name: &str,
|
|
buf: Option<BufferId>,
|
|
) -> Result<ConfigChange, ConfigError> {
|
|
let (mutability, default) = {
|
|
let def = self.definition(name)?;
|
|
(def.mutability, def.default.clone())
|
|
};
|
|
if let Some(id) = buf {
|
|
let global_effective = self.global.get(name).cloned().unwrap_or(default);
|
|
let old = self
|
|
.locals
|
|
.get(&id)
|
|
.and_then(|m| m.get(name))
|
|
.cloned()
|
|
.unwrap_or_else(|| global_effective.clone());
|
|
if let Some(m) = self.locals.get_mut(&id) {
|
|
m.remove(name);
|
|
}
|
|
let new = global_effective;
|
|
let changed = old != new;
|
|
if changed {
|
|
self.value_epoch = self.value_epoch.saturating_add(1);
|
|
}
|
|
return Ok(ConfigChange { changed, old, new });
|
|
}
|
|
|
|
if mutability == ConfigMutability::StartupOnly && self.frozen {
|
|
return Err(ConfigError::StartupOnlyAfterFreeze {
|
|
name: name.to_owned(),
|
|
});
|
|
}
|
|
let old = self
|
|
.global
|
|
.get(name)
|
|
.cloned()
|
|
.unwrap_or_else(|| default.clone());
|
|
self.global.remove(name);
|
|
let changed = old != default;
|
|
if changed {
|
|
self.value_epoch = self.value_epoch.saturating_add(1);
|
|
}
|
|
Ok(ConfigChange {
|
|
changed,
|
|
old,
|
|
new: default,
|
|
})
|
|
}
|
|
|
|
/// Drop `id`'s entire buffer-local override map. Called from the
|
|
/// existing `after_buffer_removed` choke point (Q#CR5). Fires no
|
|
/// listener (Q#CR6 (c)): the buffer is gone, so there is no
|
|
/// effective value left to observe. Does not advance the value
|
|
/// epoch either, for the same reason.
|
|
pub fn remove_buffer(&mut self, id: BufferId) {
|
|
self.locals.remove(&id);
|
|
}
|
|
|
|
// -- listeners -----------------------------------------------------
|
|
|
|
/// Register `body` to run on future effective-value changes to
|
|
/// `name`. Returns a generation-safe id, never reused, for later
|
|
/// [`Self::dispose`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`ConfigError::NotFound`] if `name` is undefined (Q#CR6 (d)).
|
|
pub fn on_change(
|
|
&mut self,
|
|
name: &str,
|
|
body: Function,
|
|
source: SourceLocation,
|
|
) -> Result<u64, ConfigError> {
|
|
self.definition(name)?;
|
|
let id = self.next_listener_id;
|
|
self.next_listener_id = self.next_listener_id.saturating_add(1);
|
|
self.listeners.push(ConfigListener {
|
|
id,
|
|
name: name.to_owned(),
|
|
body,
|
|
source,
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
/// Remove a listener by id. Idempotent: disposing an id twice, or
|
|
/// an id that was never issued, is a no-op. Generation-safe: since
|
|
/// ids are never reused, a stale handle can never dispose a newer
|
|
/// listener that happens to reuse its slot.
|
|
pub fn dispose(&mut self, id: u64) {
|
|
self.listeners.retain(|l| l.id != id);
|
|
}
|
|
|
|
/// Snapshot the listeners registered against `name`, in
|
|
/// registration order, so the caller can drop the registry borrow
|
|
/// before invoking Lua (which may re-enter the registry). This
|
|
/// registry never runs a listener itself --- mirrors
|
|
/// [`crate::hook::HookRegistry::snapshot`] exactly.
|
|
#[must_use]
|
|
pub fn snapshot(&self, name: &str) -> Vec<ConfigListener> {
|
|
self.listeners
|
|
.iter()
|
|
.filter(|l| l.name == name)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
// -- startup freeze --------------------------------------------------
|
|
|
|
/// Flip the startup freeze. One-way in practice: called once, at
|
|
/// `set_init_complete` time, from the tail of `EditorState::new()`.
|
|
/// After this, a write to a [`ConfigMutability::StartupOnly`]
|
|
/// key's global layer returns
|
|
/// [`ConfigError::StartupOnlyAfterFreeze`].
|
|
pub fn freeze(&mut self) {
|
|
self.frozen = true;
|
|
}
|
|
|
|
/// True iff [`Self::freeze`] has been called.
|
|
#[must_use]
|
|
pub fn is_frozen(&self) -> bool {
|
|
self.frozen
|
|
}
|
|
|
|
// -- epochs ------------------------------------------------------------
|
|
|
|
/// Advances by one on every successful [`Self::define`] of a new
|
|
/// name (not on an idempotent redefinition, which mutates
|
|
/// nothing).
|
|
#[must_use]
|
|
pub fn definition_epoch(&self) -> u64 {
|
|
self.definition_epoch
|
|
}
|
|
|
|
/// Advances by one on every write that changes an *effective*
|
|
/// value --- never on a write that only stores an equal-valued
|
|
/// override (F1, acceptance 15).
|
|
#[must_use]
|
|
pub fn value_epoch(&self) -> u64 {
|
|
self.value_epoch
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn src(line: i32) -> SourceLocation {
|
|
SourceLocation {
|
|
file: "test.lua".into(),
|
|
line,
|
|
}
|
|
}
|
|
|
|
fn define_bool(
|
|
r: &mut ConfigRegistry,
|
|
name: &str,
|
|
default: bool,
|
|
mutability: ConfigMutability,
|
|
) {
|
|
r.define(
|
|
name.into(),
|
|
"a boolean setting".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(default),
|
|
mutability,
|
|
src(1),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// ---- acceptance 1: round-trip every kind -------------------------------
|
|
|
|
#[test]
|
|
fn define_then_get_round_trips_every_kind() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"editing.auto-pair".into(),
|
|
"Pair brackets on insert.".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap();
|
|
r.define(
|
|
"autosave.interval-ms".into(),
|
|
"Autosave interval.".into(),
|
|
ConfigKind::Integer {
|
|
min: Some(1000),
|
|
max: None,
|
|
},
|
|
ConfigValue::Int(30_000),
|
|
ConfigMutability::Live,
|
|
src(2),
|
|
)
|
|
.unwrap();
|
|
r.define(
|
|
"editing.fill-column".into(),
|
|
"Preferred wrap column.".into(),
|
|
ConfigKind::Number {
|
|
min: Some(1.0),
|
|
max: Some(1000.0),
|
|
},
|
|
ConfigValue::Num(80.0),
|
|
ConfigMutability::Live,
|
|
src(3),
|
|
)
|
|
.unwrap();
|
|
r.define(
|
|
"editing.comment-prefix".into(),
|
|
"Comment prefix string.".into(),
|
|
ConfigKind::String { allow_empty: true },
|
|
ConfigValue::Str(String::new()),
|
|
ConfigMutability::Live,
|
|
src(4),
|
|
)
|
|
.unwrap();
|
|
r.define(
|
|
"editing.line-ending".into(),
|
|
"Line ending style.".into(),
|
|
ConfigKind::Enum {
|
|
choices: vec!["lf".into(), "crlf".into()],
|
|
},
|
|
ConfigValue::Str("lf".into()),
|
|
ConfigMutability::Live,
|
|
src(5),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
r.get("editing.auto-pair", None).unwrap(),
|
|
&ConfigValue::Bool(true)
|
|
);
|
|
assert_eq!(
|
|
r.get("autosave.interval-ms", None).unwrap(),
|
|
&ConfigValue::Int(30_000)
|
|
);
|
|
assert_eq!(
|
|
r.get("editing.fill-column", None).unwrap(),
|
|
&ConfigValue::Num(80.0)
|
|
);
|
|
assert_eq!(
|
|
r.get("editing.comment-prefix", None).unwrap(),
|
|
&ConfigValue::Str(String::new())
|
|
);
|
|
assert_eq!(
|
|
r.get("editing.line-ending", None).unwrap(),
|
|
&ConfigValue::Str("lf".into())
|
|
);
|
|
}
|
|
|
|
// ---- acceptance 2: define-time rejections ------------------------------
|
|
|
|
#[test]
|
|
fn define_rejects_empty_name() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
String::new(),
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::EmptyName));
|
|
assert_eq!(r.len(), 0);
|
|
assert_eq!(r.definition_epoch(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_whitespace_only_description() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
" \n\t ".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::MissingDescription { name } if name == "editing.x"));
|
|
assert_eq!(r.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_conflicting_duplicate_without_mutating() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let epoch_before = r.definition_epoch();
|
|
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"a different description".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(9),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::DuplicateName { name } if name == "editing.x"));
|
|
assert_eq!(
|
|
r.definition_epoch(),
|
|
epoch_before,
|
|
"no epoch advance on rejection"
|
|
);
|
|
assert_eq!(
|
|
r.get_definition("editing.x").unwrap().description,
|
|
"a boolean setting"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_non_finite_bounds() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::Number {
|
|
min: Some(f64::NAN),
|
|
max: None,
|
|
},
|
|
ConfigValue::Num(1.0),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::NonFiniteNumber { name, .. } if name == "editing.x"));
|
|
assert_eq!(r.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_inverted_range() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::Integer {
|
|
min: Some(10),
|
|
max: Some(5),
|
|
},
|
|
ConfigValue::Int(7),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::OutOfRange { name, .. } if name == "editing.x"));
|
|
assert_eq!(r.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_an_enum_with_no_choices_pointedly() {
|
|
// Without the direct check this is still rejected, but only
|
|
// indirectly: the default fails `NotAChoice` against an empty
|
|
// list, which names the wrong field. Assert the pointed error
|
|
// so a regression to the indirect path is visible.
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::Enum { choices: vec![] },
|
|
ConfigValue::Str("a".into()),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, ConfigError::EmptyChoices { name } if name == "editing.x"),
|
|
"an empty choice list must be reported as such, not as a bad default"
|
|
);
|
|
assert_eq!(r.len(), 0, "a rejected define registers nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_duplicate_enum_choices() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::Enum {
|
|
choices: vec!["a".into(), "b".into(), "a".into()],
|
|
},
|
|
ConfigValue::Str("a".into()),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, ConfigError::DuplicateChoice { name, choice } if name == "editing.x" && choice == "a")
|
|
);
|
|
assert_eq!(r.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_default_violating_its_own_contract() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::Integer {
|
|
min: Some(1000),
|
|
max: None,
|
|
},
|
|
ConfigValue::Int(500), // below its own minimum
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::OutOfRange { name, .. } if name == "editing.x"));
|
|
assert_eq!(r.len(), 0);
|
|
|
|
let err2 = r
|
|
.define(
|
|
"editing.y".into(),
|
|
"y".into(),
|
|
ConfigKind::Enum {
|
|
choices: vec!["lf".into(), "crlf".into()],
|
|
},
|
|
ConfigValue::Str("cr".into()), // not a choice
|
|
ConfigMutability::Live,
|
|
src(2),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err2, ConfigError::NotAChoice { name, .. } if name == "editing.y"));
|
|
}
|
|
|
|
// ---- acceptance 3: name grammar edge cases -----------------------------
|
|
|
|
#[test]
|
|
fn define_rejects_trailing_hyphen() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"auto-".into(),
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::InvalidName { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_doubled_hyphen() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"a--b".into(),
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::InvalidName { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_empty_segment() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing..auto-pair".into(),
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::InvalidName { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_leading_digit() {
|
|
let mut r = ConfigRegistry::new();
|
|
let err = r
|
|
.define(
|
|
"editing.1auto".into(),
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::InvalidName { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn define_rejects_overlength_name() {
|
|
let mut r = ConfigRegistry::new();
|
|
let long = "a".repeat(129);
|
|
let err = r
|
|
.define(
|
|
long,
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::InvalidName { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn define_accepts_well_formed_kebab_dotted_names() {
|
|
let mut r = ConfigRegistry::new();
|
|
for name in [
|
|
"a",
|
|
"editing.auto-pair",
|
|
"autosave.interval-ms",
|
|
"a.b.c-d-e",
|
|
] {
|
|
define_bool(&mut r, name, true, ConfigMutability::Live);
|
|
}
|
|
assert_eq!(r.len(), 4);
|
|
}
|
|
|
|
// ---- acceptance 4: idempotent vs conflicting redefinition --------------
|
|
|
|
#[test]
|
|
fn identical_redefinition_is_idempotent_and_keeps_original_source() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"editing.x".into(),
|
|
"desc".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(10),
|
|
)
|
|
.unwrap();
|
|
// Pin an override so we can prove it survives too.
|
|
r.set("editing.x", ConfigValue::Bool(false)).unwrap();
|
|
let epoch_before = r.definition_epoch();
|
|
|
|
r.define(
|
|
"editing.x".into(),
|
|
"desc".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(99), // different call site: same reload, different line
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(r.len(), 1);
|
|
assert_eq!(
|
|
r.definition_epoch(),
|
|
epoch_before,
|
|
"idempotent reload adds nothing"
|
|
);
|
|
assert_eq!(r.get_definition("editing.x").unwrap().source.line, 10);
|
|
assert_eq!(
|
|
r.get("editing.x", None).unwrap(),
|
|
&ConfigValue::Bool(false),
|
|
"the override survives an idempotent reload"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conflicting_redefinition_leaves_original_exact() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"editing.x".into(),
|
|
"original".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(10),
|
|
)
|
|
.unwrap();
|
|
|
|
let err = r
|
|
.define(
|
|
"editing.x".into(),
|
|
"changed".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(false), // different default
|
|
ConfigMutability::Live,
|
|
src(20),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::DuplicateName { .. }));
|
|
|
|
let def = r.get_definition("editing.x").unwrap();
|
|
assert_eq!(def.description, "original");
|
|
assert_eq!(def.default, ConfigValue::Bool(true));
|
|
assert_eq!(def.source.line, 10);
|
|
}
|
|
|
|
// ---- acceptance 8: stable ordering --------------------------------------
|
|
|
|
#[test]
|
|
fn names_are_stable_across_at_least_three_defines() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "c", true, ConfigMutability::Live);
|
|
define_bool(&mut r, "a", true, ConfigMutability::Live);
|
|
define_bool(&mut r, "b", true, ConfigMutability::Live);
|
|
assert_eq!(r.names(), &["c".to_owned(), "a".into(), "b".into()]);
|
|
}
|
|
|
|
// ---- acceptance 9 (bonus): source captured verbatim ---------------------
|
|
|
|
#[test]
|
|
fn source_location_is_captured_from_the_defining_module() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"editing.auto-pair".into(),
|
|
"x".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
SourceLocation {
|
|
file: "pair.lua".into(),
|
|
line: 12,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
r.get_definition("editing.auto-pair")
|
|
.unwrap()
|
|
.source
|
|
.render(),
|
|
"pair.lua:12"
|
|
);
|
|
}
|
|
|
|
// ---- acceptance 10 / 14: resolution and F9 ------------------------------
|
|
|
|
#[test]
|
|
fn get_resolves_local_then_global_then_default() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let buf = BufferId::next();
|
|
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(true)
|
|
);
|
|
|
|
r.set("editing.x", ConfigValue::Bool(false)).unwrap();
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(false)
|
|
);
|
|
|
|
r.set_local(buf, "editing.x", ConfigValue::Bool(true))
|
|
.unwrap();
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(true)
|
|
);
|
|
|
|
r.reset("editing.x", Some(buf)).unwrap();
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(false)
|
|
);
|
|
|
|
r.reset("editing.x", None).unwrap();
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_with_no_buffer_ignores_any_local_override() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let buf = BufferId::next();
|
|
r.set_local(buf, "editing.x", ConfigValue::Bool(false))
|
|
.unwrap();
|
|
|
|
// F9: get(name) with no buffer never consults an "active"
|
|
// buffer. It must see the global chain only, even though a
|
|
// buffer somewhere holds a different local override.
|
|
assert_eq!(r.get("editing.x", None).unwrap(), &ConfigValue::Bool(true));
|
|
}
|
|
|
|
// ---- acceptance 11: THE F1 test -----------------------------------------
|
|
|
|
#[test]
|
|
fn equal_valued_local_override_is_still_stored_and_shields_buffer() {
|
|
// This is the bite-verified regression test for framing F1.
|
|
//
|
|
// Scenario: a setting defaults to `true`. A buffer is pinned
|
|
// to `true` via set_local -- at the moment of pinning, the
|
|
// local override is *equal* to the effective global value, so
|
|
// an implementation that treats "equal-valued set" as a true
|
|
// no-op would store nothing for that buffer. Then the global
|
|
// value flips to `false`. If the local override was never
|
|
// stored, the "pinned" buffer silently flips too -- the pin
|
|
// never existed. The fix (Q#CR2/F1): overrides are *always*
|
|
// stored, even when they equal the value they shadow; only
|
|
// the value epoch and listener dispatch key on effective
|
|
// change. This test fails against the naive "true no-op"
|
|
// implementation and passes against the always-store one.
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.auto-pair", true, ConfigMutability::Live);
|
|
let buf = BufferId::next();
|
|
|
|
// Global effective value is `true` (the default; no override
|
|
// yet). Pin the buffer to that same value.
|
|
let pin = r
|
|
.set_local(buf, "editing.auto-pair", ConfigValue::Bool(true))
|
|
.unwrap();
|
|
assert!(
|
|
!pin.changed,
|
|
"pinning to the current value is observationally silent"
|
|
);
|
|
assert!(
|
|
r.is_set("editing.auto-pair", Some(buf)).unwrap(),
|
|
"but the override IS stored -- is_set must see it"
|
|
);
|
|
|
|
// Now flip the global value.
|
|
let flip = r
|
|
.set("editing.auto-pair", ConfigValue::Bool(false))
|
|
.unwrap();
|
|
assert!(flip.changed);
|
|
|
|
// The pinned buffer must NOT have flipped.
|
|
assert_eq!(
|
|
r.get("editing.auto-pair", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(true),
|
|
"F1: the buffer-local pin must survive a later global change"
|
|
);
|
|
// The global (no-buffer) chain must reflect the flip.
|
|
assert_eq!(
|
|
r.get("editing.auto-pair", None).unwrap(),
|
|
&ConfigValue::Bool(false)
|
|
);
|
|
}
|
|
|
|
// ---- acceptance 12: per-buffer isolation + is_set -----------------------
|
|
|
|
#[test]
|
|
fn set_local_on_one_buffer_does_not_affect_another() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let a = BufferId::next();
|
|
let b = BufferId::next();
|
|
|
|
r.set_local(a, "editing.x", ConfigValue::Bool(false))
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
r.get("editing.x", Some(a)).unwrap(),
|
|
&ConfigValue::Bool(false)
|
|
);
|
|
assert_eq!(
|
|
r.get("editing.x", Some(b)).unwrap(),
|
|
&ConfigValue::Bool(true)
|
|
);
|
|
assert!(r.is_set("editing.x", Some(a)).unwrap());
|
|
assert!(!r.is_set("editing.x", Some(b)).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn is_set_reports_presence_even_for_an_equal_valued_override() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
assert!(!r.is_set("editing.x", None).unwrap());
|
|
r.set("editing.x", ConfigValue::Bool(true)).unwrap(); // equal to default
|
|
assert!(r.is_set("editing.x", None).unwrap());
|
|
}
|
|
|
|
// ---- acceptance 13: buffer-local lifecycle -------------------------------
|
|
|
|
#[test]
|
|
fn remove_buffer_drops_its_local_overrides() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let buf = BufferId::next();
|
|
r.set_local(buf, "editing.x", ConfigValue::Bool(false))
|
|
.unwrap();
|
|
assert!(r.is_set("editing.x", Some(buf)).unwrap());
|
|
|
|
r.remove_buffer(buf);
|
|
|
|
assert!(!r.is_set("editing.x", Some(buf)).unwrap());
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locals_persist_until_remove_buffer_is_explicitly_called() {
|
|
// Documents the Q#CR5/F8 contract this module owns: without an
|
|
// explicit remove_buffer call (which in production rides
|
|
// after_buffer_removed), a buffer's locals are never purged.
|
|
// A BufferRegistry::remove bypass that skips that choke point
|
|
// is out of this file's scope (it lives in editor.rs), but the
|
|
// half of the contract this registry is responsible for --
|
|
// "no automatic purge exists" -- is directly testable here.
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let buf = BufferId::next();
|
|
r.set_local(buf, "editing.x", ConfigValue::Bool(false))
|
|
.unwrap();
|
|
// ... time passes, nothing calls remove_buffer ...
|
|
assert!(
|
|
r.is_set("editing.x", Some(buf)).unwrap(),
|
|
"leak is permanent until purged"
|
|
);
|
|
}
|
|
|
|
// ---- acceptance 15: epoch discipline -------------------------------------
|
|
|
|
#[test]
|
|
fn value_epoch_advances_only_on_effective_change() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let epoch0 = r.value_epoch();
|
|
|
|
let noop = r.set("editing.x", ConfigValue::Bool(true)).unwrap(); // equal to default
|
|
assert!(!noop.changed);
|
|
assert_eq!(
|
|
r.value_epoch(),
|
|
epoch0,
|
|
"equal-valued override advances no epoch"
|
|
);
|
|
|
|
let real = r.set("editing.x", ConfigValue::Bool(false)).unwrap();
|
|
assert!(real.changed);
|
|
assert_eq!(r.value_epoch(), epoch0 + 1);
|
|
|
|
let buf = BufferId::next();
|
|
let local_noop = r
|
|
.set_local(buf, "editing.x", ConfigValue::Bool(false))
|
|
.unwrap(); // equal to current global
|
|
assert!(!local_noop.changed);
|
|
assert_eq!(r.value_epoch(), epoch0 + 1, "still no advance");
|
|
|
|
let local_real = r
|
|
.set_local(buf, "editing.x", ConfigValue::Bool(true))
|
|
.unwrap();
|
|
assert!(local_real.changed);
|
|
assert_eq!(r.value_epoch(), epoch0 + 2);
|
|
}
|
|
|
|
#[test]
|
|
fn definition_epoch_advances_only_on_new_definitions() {
|
|
let mut r = ConfigRegistry::new();
|
|
assert_eq!(r.definition_epoch(), 0);
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
assert_eq!(r.definition_epoch(), 1);
|
|
// Idempotent redefinition: no advance.
|
|
r.define(
|
|
"editing.x".into(),
|
|
"a boolean setting".into(),
|
|
ConfigKind::Boolean,
|
|
ConfigValue::Bool(true),
|
|
ConfigMutability::Live,
|
|
src(2),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r.definition_epoch(), 1);
|
|
}
|
|
|
|
// ---- the validate() seam --------------------------------------------------
|
|
|
|
#[test]
|
|
fn validate_checks_without_committing() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"autosave.interval-ms".into(),
|
|
"x".into(),
|
|
ConfigKind::Integer {
|
|
min: Some(1000),
|
|
max: None,
|
|
},
|
|
ConfigValue::Int(30_000),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap();
|
|
|
|
let err = r
|
|
.validate("autosave.interval-ms", &ConfigValue::Int(500))
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::OutOfRange { .. }));
|
|
// Nothing was mutated by the failed validation.
|
|
assert!(!r.is_set("autosave.interval-ms", None).unwrap());
|
|
assert_eq!(r.value_epoch(), 0);
|
|
|
|
assert!(
|
|
r.validate("autosave.interval-ms", &ConfigValue::Int(5000))
|
|
.is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_on_undefined_name_is_not_found() {
|
|
let r = ConfigRegistry::new();
|
|
let err = r.validate("nope", &ConfigValue::Bool(true)).unwrap_err();
|
|
assert!(matches!(err, ConfigError::NotFound { name } if name == "nope"));
|
|
}
|
|
|
|
// ---- type mismatch and per-kind validation ---------------------------------
|
|
|
|
#[test]
|
|
fn set_rejects_wrong_type() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let err = r.set("editing.x", ConfigValue::Int(1)).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
ConfigError::TypeMismatch {
|
|
expected: "boolean",
|
|
got: "integer",
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn string_kind_rejects_empty_unless_allowed() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::String { allow_empty: false },
|
|
ConfigValue::Str("nonempty".into()),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap();
|
|
let err = r
|
|
.set("editing.x", ConfigValue::Str(String::new()))
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::EmptyString { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn enum_kind_rejects_non_choice() {
|
|
let mut r = ConfigRegistry::new();
|
|
r.define(
|
|
"editing.x".into(),
|
|
"x".into(),
|
|
ConfigKind::Enum {
|
|
choices: vec!["lf".into(), "crlf".into()],
|
|
},
|
|
ConfigValue::Str("lf".into()),
|
|
ConfigMutability::Live,
|
|
src(1),
|
|
)
|
|
.unwrap();
|
|
let err = r
|
|
.set("editing.x", ConfigValue::Str("cr".into()))
|
|
.unwrap_err();
|
|
assert!(matches!(err, ConfigError::NotAChoice { got, .. } if got == "cr"));
|
|
}
|
|
|
|
// ---- listeners: registration, snapshot, dispose -----------------------
|
|
|
|
#[test]
|
|
fn on_change_on_undefined_name_raises_not_found() {
|
|
let lua = mlua::Lua::new();
|
|
let mut r = ConfigRegistry::new();
|
|
let f = lua.create_function(|_, ()| Ok(())).unwrap();
|
|
let err = r.on_change("nope", f, src(1)).unwrap_err();
|
|
assert!(matches!(err, ConfigError::NotFound { name } if name == "nope"));
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_returns_owned_listeners_in_registration_order_and_survives_registry_drop() {
|
|
let lua = mlua::Lua::new();
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
for line in 10..13 {
|
|
let f = lua.create_function(|_, ()| Ok(())).unwrap();
|
|
r.on_change("editing.x", f, src(line)).unwrap();
|
|
}
|
|
let snap = r.snapshot("editing.x");
|
|
assert_eq!(snap.len(), 3);
|
|
assert_eq!(
|
|
snap.iter().map(|l| l.source.line).collect::<Vec<_>>(),
|
|
vec![10, 11, 12]
|
|
);
|
|
|
|
// Bite-verify borrow release: the registry itself can be
|
|
// dropped and the snapshot's Lua functions are still callable
|
|
// -- proving the caller genuinely does not need to hold the
|
|
// registry borrow while invoking them.
|
|
drop(r);
|
|
for l in &snap {
|
|
l.body.call::<()>(()).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dispose_is_idempotent_and_id_generation_safe() {
|
|
let lua = mlua::Lua::new();
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let f1 = lua.create_function(|_, ()| Ok(())).unwrap();
|
|
let id1 = r.on_change("editing.x", f1, src(1)).unwrap();
|
|
|
|
r.dispose(id1);
|
|
assert!(r.snapshot("editing.x").is_empty());
|
|
r.dispose(id1); // idempotent: no panic, no effect
|
|
assert!(r.snapshot("editing.x").is_empty());
|
|
|
|
let f2 = lua.create_function(|_, ()| Ok(())).unwrap();
|
|
let id2 = r.on_change("editing.x", f2, src(2)).unwrap();
|
|
assert_ne!(id1, id2);
|
|
|
|
// A stale id must never dispose a newer listener.
|
|
r.dispose(id1);
|
|
assert_eq!(r.snapshot("editing.x").len(), 1);
|
|
assert_eq!(r.snapshot("editing.x")[0].id, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn global_set_does_not_change_a_shadowed_buffers_effective_value() {
|
|
// Q#CR6 (b): a global `set` fires only the global-scoped
|
|
// notification; a buffer holding its own override is
|
|
// shadowed, so its effective value must not move.
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(&mut r, "editing.x", true, ConfigMutability::Live);
|
|
let buf = BufferId::next();
|
|
r.set_local(buf, "editing.x", ConfigValue::Bool(true))
|
|
.unwrap();
|
|
|
|
let change = r.set("editing.x", ConfigValue::Bool(false)).unwrap();
|
|
assert!(change.changed, "the global effective value did change");
|
|
assert_eq!(
|
|
r.get("editing.x", Some(buf)).unwrap(),
|
|
&ConfigValue::Bool(true),
|
|
"the shadowed buffer's effective value must not move"
|
|
);
|
|
}
|
|
|
|
// ---- mutability and the startup freeze ---------------------------------
|
|
|
|
#[test]
|
|
fn startup_only_key_accepts_writes_before_freeze_and_rejects_after() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(
|
|
&mut r,
|
|
"lsp.root-markers",
|
|
true,
|
|
ConfigMutability::StartupOnly,
|
|
);
|
|
|
|
// NOTE: in --lib test builds, set_init_complete never runs and
|
|
// the freeze flag never flips on its own -- this test flips it
|
|
// explicitly, the way mod.rs's own acceptance tests do, so it
|
|
// does not pass vacuously.
|
|
assert!(r.set("lsp.root-markers", ConfigValue::Bool(false)).is_ok());
|
|
|
|
r.freeze();
|
|
assert!(r.is_frozen());
|
|
|
|
let err = r
|
|
.set("lsp.root-markers", ConfigValue::Bool(true))
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, ConfigError::StartupOnlyAfterFreeze { name } if name == "lsp.root-markers")
|
|
);
|
|
|
|
let err2 = r.reset("lsp.root-markers", None).unwrap_err();
|
|
assert!(matches!(err2, ConfigError::StartupOnlyAfterFreeze { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn startup_only_key_always_rejects_set_local() {
|
|
let mut r = ConfigRegistry::new();
|
|
define_bool(
|
|
&mut r,
|
|
"lsp.root-markers",
|
|
true,
|
|
ConfigMutability::StartupOnly,
|
|
);
|
|
let buf = BufferId::next();
|
|
|
|
// Rejected even before freeze: the combination is banned
|
|
// outright (F5), not just after startup completes.
|
|
assert!(!r.is_frozen());
|
|
let err = r
|
|
.set_local(buf, "lsp.root-markers", ConfigValue::Bool(false))
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, ConfigError::StartupOnlyLocal { name } if name == "lsp.root-markers")
|
|
);
|
|
}
|
|
|
|
// ---- ConfigValue::int_from_f64 (acceptance 6's pure-Rust half) --------
|
|
|
|
#[test]
|
|
fn int_from_f64_accepts_exact_values() {
|
|
assert_eq!(
|
|
ConfigValue::int_from_f64("x", 3.0).unwrap(),
|
|
ConfigValue::Int(3)
|
|
);
|
|
assert_eq!(
|
|
ConfigValue::int_from_f64("x", -1500.0).unwrap(),
|
|
ConfigValue::Int(-1500)
|
|
);
|
|
assert_eq!(
|
|
ConfigValue::int_from_f64("x", 0.0).unwrap(),
|
|
ConfigValue::Int(0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn int_from_f64_rejects_fractional_values() {
|
|
let err = ConfigValue::int_from_f64("x", 1500.7).unwrap_err();
|
|
assert!(matches!(err, ConfigError::NonIntegral { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn int_from_f64_rejects_non_finite_values() {
|
|
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
|
let err = ConfigValue::int_from_f64("x", v).unwrap_err();
|
|
assert!(matches!(err, ConfigError::NonFiniteNumber { .. }));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn int_from_f64_rejects_the_saturating_upper_boundary() {
|
|
// Review round 1, finding 1. `i64::MAX as f64` rounds UP to
|
|
// 2^63 = 9223372036854775808.0, one more than i64::MAX. With a
|
|
// `>` guard this value passes validation and `v as i64` then
|
|
// SATURATES to 9223372036854775807 --- the registry silently
|
|
// stores a different number than the caller asked for. This
|
|
// test fails against the `>` form.
|
|
let boundary = i64::MAX as f64;
|
|
let err = ConfigValue::int_from_f64("x", boundary).unwrap_err();
|
|
assert!(
|
|
matches!(err, ConfigError::NonIntegral { .. }),
|
|
"2^63 must be rejected, not saturated to i64::MAX"
|
|
);
|
|
// Anything beyond it too.
|
|
assert!(ConfigValue::int_from_f64("x", boundary * 2.0).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn int_from_f64_accepts_the_exact_lower_boundary() {
|
|
// The bounds are asymmetric on purpose: unlike the upper one,
|
|
// `i64::MIN as f64` IS exactly i64::MIN and round-trips, so
|
|
// tightening the lower comparison to `<=` alongside the upper
|
|
// `>=` would wrongly reject a legitimate value.
|
|
let lo = i64::MIN as f64;
|
|
assert_eq!(
|
|
ConfigValue::int_from_f64("x", lo).unwrap(),
|
|
ConfigValue::Int(i64::MIN),
|
|
"i64::MIN is representable and must still be accepted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn int_from_f64_accepts_the_largest_representable_integer_below_the_boundary() {
|
|
// The next f64 below 2^63 is 2^63 - 1024, which is a valid i64.
|
|
// Pins that the `>=` fix did not over-reject the top of range.
|
|
let below = (i64::MAX as f64) - 1024.0;
|
|
let got = ConfigValue::int_from_f64("x", below).unwrap();
|
|
assert_eq!(got, ConfigValue::Int(9_223_372_036_854_774_784));
|
|
}
|
|
}
|