pmacs/src/buffer_registry.rs

272 lines
9.4 KiB
Rust

// buffer_registry.rs --- Buffer registry: opaque IDs resolved through Rust.
//! Owns [`Buffer`]s behind opaque [`BufferId`] handles.
//!
//! # Why a registry
//!
//! Spec §3 Checkpoint 6 and the Lua boundary rules (R52, R53) keep raw
//! `Arc<Mutex<Buffer>>` away from Lua. Lua holds `BufferId` values
//! (cheap, `Copy`, no GC entanglement); every method call resolves the
//! ID through this registry. A removed buffer leaves all live IDs as
//! *stale* handles, and the next resolution returns
//! [`RegistryError::Missing`] --- a typed error, not a use-after-free.
//!
//! # Threading
//!
//! Single-threaded by construction. The registry lives next to the
//! [`crate::lua::LuaHost`] on the main thread; workers (M3) communicate
//! via typed messages and snapshots, never by sharing the registry.
use std::collections::HashMap;
use thiserror::Error;
use crate::buffer::{Buffer, BufferId};
/// Errors raised by the registry.
#[derive(Debug, Error)]
pub enum RegistryError {
/// The supplied [`BufferId`] is not currently registered. The handle
/// is stale: it referred to a buffer that has since been removed (or
/// was never registered with this instance).
#[error("no buffer registered with id {id:?}")]
Missing {
/// The offending ID, preserved for structured surfacing across
/// the Lua boundary (R52).
id: BufferId,
},
/// Removal was requested for a buffer that is currently mid-edit
/// (T M7.4). The most common path: a Lua intercept body on buffer
/// `A` called `pmacs.buffer.remove(A)`. Mirrors
/// [`crate::buffer::BufferError::ConcurrentEdit`] in spirit; the
/// message names the workaround per project convention.
#[error(
"buffer `{name}` (id {id:?}) is already being edited; \
it cannot be removed while an intercept is running on it. \
To remove this buffer, return from the intercept first \
(the registry borrow will release at that point), or remove \
a different buffer."
)]
ConcurrentEdit {
/// The buffer's identifier.
id: BufferId,
/// The buffer's name, for diagnostics.
name: String,
},
}
/// Owns [`Buffer`]s behind their [`BufferId`]s.
///
/// Construction goes through [`Self::new`] or [`Self::default`]. Buffers
/// are inserted by [`Self::create`], [`Self::create_from_bytes`], or
/// [`Self::insert`] (which takes a pre-built [`Buffer`] --- useful when a
/// buffer is loaded from disk before being handed off to the registry).
#[derive(Default)]
pub struct BufferRegistry {
buffers: HashMap<BufferId, Buffer>,
/// Insertion-order list of IDs. Used for stable iteration / listing
/// (`HashMap` iteration order is unspecified, which would make
/// `pmacs.buffer.list()` non-deterministic).
order: Vec<BufferId>,
}
impl BufferRegistry {
/// Construct an empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Insert an already-constructed [`Buffer`]. Returns its [`BufferId`].
///
/// If a buffer with the same ID is already present (which would
/// indicate a programming error --- IDs are allocated by the global
/// counter and unique), the old buffer is overwritten and removed
/// from the order list before the new one is appended.
pub fn insert(&mut self, buffer: Buffer) -> BufferId {
let id = buffer.id();
if self.buffers.insert(id, buffer).is_some() {
// Defensive: keep `order` consistent if someone re-inserted.
self.order.retain(|x| *x != id);
}
self.order.push(id);
id
}
/// Create a fresh empty [`Buffer`] under a new [`BufferId`].
pub fn create(&mut self, name: impl Into<String>) -> BufferId {
self.insert(Buffer::new(BufferId::next(), name))
}
/// Create a [`Buffer`] seeded with `bytes` under a new [`BufferId`].
pub fn create_from_bytes(&mut self, name: impl Into<String>, bytes: &[u8]) -> BufferId {
self.insert(Buffer::from_bytes(BufferId::next(), name, bytes))
}
/// Resolve an ID to a shared reference. Returns
/// [`RegistryError::Missing`] for stale handles (R53).
pub fn get(&self, id: BufferId) -> Result<&Buffer, RegistryError> {
self.buffers.get(&id).ok_or(RegistryError::Missing { id })
}
/// Resolve an ID to an exclusive reference.
pub fn get_mut(&mut self, id: BufferId) -> Result<&mut Buffer, RegistryError> {
self.buffers
.get_mut(&id)
.ok_or(RegistryError::Missing { id })
}
/// Remove and return the buffer behind `id`. Subsequent lookups of
/// `id` produce [`RegistryError::Missing`].
///
/// Refuses to remove a buffer that is currently mid-edit (T M7.4):
/// an intercept body running on buffer `A` cannot drop `A` out
/// from under itself. Returns
/// [`RegistryError::ConcurrentEdit`] in that case, leaving the
/// buffer in place.
pub fn remove(&mut self, id: BufferId) -> Result<Buffer, RegistryError> {
// Peek without taking ownership: if the buffer is mid-edit we
// surface a typed error and leave the registry untouched.
if let Some(buf) = self.buffers.get(&id)
&& buf.editing_in_progress()
{
return Err(RegistryError::ConcurrentEdit {
id,
name: buf.name().to_string(),
});
}
let buf = self
.buffers
.remove(&id)
.ok_or(RegistryError::Missing { id })?;
self.order.retain(|x| *x != id);
Ok(buf)
}
/// True iff `id` currently resolves.
#[must_use]
pub fn contains(&self, id: BufferId) -> bool {
self.buffers.contains_key(&id)
}
/// First buffer whose name matches `name` exactly, in insertion
/// order. Used by host code that addresses well-known buffers
/// (e.g. `*errors*`) without holding their `BufferId`.
#[must_use]
pub fn find_by_name(&self, name: &str) -> Option<BufferId> {
self.order
.iter()
.copied()
.find(|id| self.buffers.get(id).is_some_and(|b| b.name() == name))
}
/// First buffer bound to `path` on disk, in insertion order.
/// T M4.5 L1: matches on the per-buffer `file_path` (set at
/// load), so cross-file navigation reuses an already-open file
/// instead of creating a duplicate buffer.
#[must_use]
pub fn find_by_path(&self, path: &std::path::Path) -> Option<BufferId> {
self.order.iter().copied().find(|id| {
self.buffers
.get(id)
.is_some_and(|b| b.file_path() == Some(path))
})
}
/// IDs in insertion order. Stable: preserved across non-removing
/// operations.
#[must_use]
pub fn ids(&self) -> &[BufferId] {
&self.order
}
/// Number of registered buffers.
#[must_use]
pub fn len(&self) -> usize {
self.buffers.len()
}
/// True iff no buffers are registered.
#[must_use]
pub fn is_empty(&self) -> bool {
self.buffers.is_empty()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_then_get_round_trips() {
let mut r = BufferRegistry::new();
let id = r.create("alpha");
assert_eq!(r.len(), 1);
let b = r.get(id).unwrap();
assert_eq!(b.name(), "alpha");
assert_eq!(b.len(), 0);
}
#[test]
fn create_from_bytes_seeds_content() {
let mut r = BufferRegistry::new();
let id = r.create_from_bytes("seed", b"hello");
assert_eq!(r.get(id).unwrap().len(), 5);
}
#[test]
fn missing_id_returns_typed_error() {
// Avoids requiring Debug on Buffer (Buffer has trait-object views
// that don't carry Debug), so we destructure the error directly.
let r = BufferRegistry::new();
let stale = BufferId::next();
let err = r.get(stale).err().expect("expected stale-handle error");
match err {
RegistryError::Missing { id } => assert_eq!(id, stale),
other @ RegistryError::ConcurrentEdit { .. } => {
panic!("expected Missing, got {other:?}")
}
}
}
#[test]
fn remove_invalidates_handle() {
let mut r = BufferRegistry::new();
let id = r.create("doomed");
r.remove(id).unwrap();
assert!(!r.contains(id));
assert!(r.get(id).is_err());
assert!(r.remove(id).is_err());
assert_eq!(r.len(), 0);
}
#[test]
fn find_by_name_returns_first_match() {
let mut r = BufferRegistry::new();
let a = r.create("alpha");
let _b = r.create("beta");
let alpha2 = r.create("alpha");
// Insertion order: returns the first.
assert_eq!(r.find_by_name("alpha"), Some(a));
// Once removed, the second alpha takes the slot.
r.remove(a).unwrap();
assert_eq!(r.find_by_name("alpha"), Some(alpha2));
assert_eq!(r.find_by_name("nope"), None);
}
#[test]
fn ids_preserved_in_insertion_order() {
let mut r = BufferRegistry::new();
let a = r.create("a");
let b = r.create("b");
let c = r.create("c");
assert_eq!(r.ids(), &[a, b, c]);
r.remove(b).unwrap();
assert_eq!(r.ids(), &[a, c]);
}
}