wip(stage2a): name provenance plus the View rename hook

`BufferNameOrigin` records where a buffer's name came from instead of
inferring it from the string: a path-backed buffer's name is the path
*as given*, so a relative open is named `foo.rs` while its stored path
is absolute, and a user may legitimately choose a name that normalizes
to its own file's path. Rename reconciliation asks the bit.

Every path-backed creation site is audited onto the new
`set_path_derived_name` door: `EditorCore::get_or_load_buffer`, the
`NotFound` arm of `resolve_target_buffer`, `pmacs.buffer.from_file`,
and `pmacs.buffer.find_or_open`. Ordinary `Buffer::set_name` records
`Explicit`.

`View::rename_resource` is the seam that re-roots a URI-keyed overlay
in place, so it keeps its position in the window's composition order;
`DiagnosticView` overrides it, whose `uri` is private and set once at
construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-29 17:38:21 -04:00
parent e003b81cdd
commit 4f6135263b
5 changed files with 107 additions and 3 deletions

View File

@ -148,6 +148,26 @@ struct EditDescription {
inserted_len: u64,
}
/// Provenance of a [`Buffer`]'s name (dired Stage 2a, Q#DR30).
///
/// A rename must move a name that merely *renders* the file's path and
/// must leave a name the user chose alone. String inspection cannot
/// tell those apart — a user may legitimately name a buffer with a
/// string that normalizes to its own path — so the fact is recorded at
/// the moment the name is written instead of being reconstructed
/// later.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BufferNameOrigin {
/// A caller named this buffer: `Buffer::new`/`from_bytes`, an
/// ordinary [`Buffer::set_name`], or the `pmacs.buffer.set_name`
/// binding. A rename leaves the name alone.
Explicit,
/// The name was derived from the buffer's backing path by a
/// path-backed creation site, through
/// [`Buffer::set_path_derived_name`]. A rename rewrites it.
PathDerived,
}
/// The unit of editable content: rope + identity + views + undo.
///
/// # Threading
@ -159,6 +179,14 @@ pub struct Buffer {
id: BufferId,
rope: Rope,
name: String,
/// Where [`Self::name`] came from. Recorded rather than inferred,
/// because a path-backed buffer's name is **not** reliably its
/// path: `get_or_load_buffer` takes the name from the path *as
/// given* and normalizes only the stored `file_path`, so a
/// relative open is named `foo.rs` while its path is absolute.
/// Rename reconciliation asks this bit, never the string
/// (dired Stage 2a, Q#DR30).
name_origin: BufferNameOrigin,
/// The buffer's single active major mode, if one has been selected.
major_mode: Option<String>,
is_modified: bool,
@ -247,6 +275,10 @@ impl Buffer {
id,
rope,
name: name.into(),
// Construction names a buffer explicitly. A path-backed
// creation site re-records provenance through
// `set_path_derived_name` right after binding the path.
name_origin: BufferNameOrigin::Explicit,
major_mode: None,
is_modified: false,
read_only: false,
@ -449,9 +481,35 @@ impl Buffer {
&self.name
}
/// Set the buffer's name. Used by save-as and rename operations.
/// Set the buffer's name, recording it as **explicitly chosen**
/// ([`BufferNameOrigin::Explicit`]).
///
/// This is the user-facing door — `pmacs.buffer.set_name` and
/// save-as go through it — and it is deliberately explicit even
/// when the string happens to denote the file: naming a buffer
/// `notes` for `${cwd}/notes` is still a naming operation, and a
/// later rename must not overwrite it. Path-backed creation sites
/// use [`Self::set_path_derived_name`] instead.
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = name.into();
self.name_origin = BufferNameOrigin::Explicit;
}
/// Set the buffer's name **and** record that it was derived from
/// the buffer's backing path ([`BufferNameOrigin::PathDerived`]).
///
/// Every site that creates or re-binds a path-backed buffer uses
/// this door, including rename reconciliation itself — so a second
/// rename still follows the path.
pub fn set_path_derived_name(&mut self, name: impl Into<String>) {
self.name = name.into();
self.name_origin = BufferNameOrigin::PathDerived;
}
/// Where this buffer's name came from (dired Stage 2a, Q#DR30).
#[must_use]
pub fn name_origin(&self) -> BufferNameOrigin {
self.name_origin
}
/// This buffer's active major mode, if any.

View File

@ -489,6 +489,17 @@ impl DiagnosticView {
}
impl View for DiagnosticView {
/// Re-root this view when the buffer's file was renamed (dired
/// Stage 2a, §5). The URI field is private and `View` has no
/// downcast, so this hook is the only way an outside sweep can
/// reach it — and mutating in place preserves this overlay's
/// position in the window's composition order.
fn rename_resource(&mut self, old_uri: &str, new_uri: &str) {
if self.uri == old_uri {
self.uri = new_uri.to_owned();
}
}
fn kind(&self) -> &'static str {
"diagnostic"
}

View File

@ -938,11 +938,18 @@ impl EditorCore {
}
let normalized = normalize_buffer_path(path.to_path_buf());
let (bytes, meta) = crate::file_io::load_file(path)?;
// The name is the path **as given** — a relative open is named
// `foo.rs` while `file_path` below is absolute. Recording the
// provenance (Q#DR30) is what lets rename reconciliation move
// this name without having to guess from the string.
let display_name = path.display().to_string();
let id = self
.registry
.borrow_mut()
.create_from_bytes(display_name, &bytes);
.create_from_bytes(display_name.clone(), &bytes);
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
b.set_path_derived_name(display_name);
}
self.set_buffer_path(id, Some(normalized));
self.set_buffer_meta(id, Some(meta));
Ok((id, true))
@ -1001,7 +1008,12 @@ impl EditorCore {
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let display_path = path.display().to_string();
let buffer_id = self.registry.borrow_mut().create(display_path);
let buffer_id = self.registry.borrow_mut().create(display_path.clone());
// Path-backed creation site (Q#DR30): the name is the
// path, so a later rename may move it.
if let Ok(b) = self.registry.borrow_mut().get_mut(buffer_id) {
b.set_path_derived_name(display_path);
}
self.set_buffer_path(buffer_id, Some(path.to_path_buf()));
"[new file]".clone_into(&mut self.status);
Ok(ResolvedTarget::Buffer {

View File

@ -3244,6 +3244,11 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
))
})?;
let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes);
// Path-backed creation site (Q#DR30): this name is the
// path as given, so rename reconciliation may move it.
if let Ok(b) = reg.borrow_mut().get_mut(id) {
b.set_path_derived_name(path.clone());
}
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let mut core = core.borrow_mut();
core.switch_active_buffer(id)
@ -3307,6 +3312,10 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
))
})?;
let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes);
// Path-backed creation site (Q#DR30), as in `from_file`.
if let Ok(b) = reg.borrow_mut().get_mut(id) {
b.set_path_derived_name(path.clone());
}
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let mut core = core.borrow_mut();
core.switch_active_buffer(id)

View File

@ -310,6 +310,20 @@ pub trait View {
fn clone_for_split(&self) -> Option<Box<dyn View>> {
None
}
/// Retarget this overlay from `old_uri` to `new_uri` after a
/// resource rename (dired Stage 2a, §5). Default: no-op — a view
/// that renders nothing URI-keyed is unaffected.
///
/// Mutates **in place**, so the overlay keeps its position in the
/// window's composition order. That is the reason this is a trait
/// hook rather than a remove-and-re-push at the call site: overlays
/// are an ordered `Vec` merged in sequence, and re-pushing would
/// move a diagnostic underline to the end of the stack. It is also
/// how *passive* windows are reached at all — the Lua attach path
/// (`pmacs.diag._attach_view`) can only touch the active window,
/// while the sweep that drives this walks every window.
fn rename_resource(&mut self, _old_uri: &str, _new_uri: &str) {}
}
// ---------------------------------------------------------------------------