fix(compile): PR #113 round 5 — buffer-level span translation, fragment preservation, tracked line start
Finding-by-finding (framing revision 11; bites via scripts/bite
against 6793edc):
1. Style-span coordinate translation belongs to the BUFFER. A new
BufferStyleSpanTranslator is attached by
pmacs.buffer.add_style_overlay and sees every edit exactly once —
bypass writes, undo/redo, remote CRDT ops — independent of window
count or visibility; the window-attached BufferStyleOverlay
copies are render-only (on_edit removed). Pre-fix each attached
view translated the shared store: start_run's explicit attach
duplicated the after-switch hook's (switch_buffer fires it
synchronously), so the normal path shifted later spans TWICE per
byte-delta rewrite, splits multiplied further, and a hidden
buffer shifted ZERO times. The redundant attach is removed;
correctness no longer depends on attachment discipline. Bites:
per-cell rendered assertions active (red a, blue bc, CR, red é →
é red, b/c blue) and hidden (run finishes with the buffer in no
window; switch back renders true colors); three direct units pin
exactly-once with extra render views attached.
2. Translation preserves the untouched fragments of a partially
overlapped span: left of the replaced range keeps its styling,
right of it shifts by the length delta, only the rewritten bytes
lose theirs (the writer styles what it writes; inserted bytes
inherit nothing). Pre-fix any overlap dropped the WHOLE span —
red abc, SGR reset, CR, X left bc unstyled; zero translation
painted the default X red instead. Bite: exact (glyph, fg) cells
X=default, b/c=red — any_styled_cell cannot see either failure.
3. The per-CR/BS/erase-line whole-prefix scan is gone:
slot.line_start is tracked — advanced at every \n (append helper
+ the mid-line newline branch), read O(1) by the rewind paths,
reset on run start/resync/raw marker appends. Measured on 2 MB of
output + 3000 CR updates (release): 2.52s pre-fix → 0.67s
post-fix (remainder is fixture-bound; pre-fix cost grows with
buffer size). No correctness bite is possible for a pure perf fix
— the committed test pins the tracked value's behavior across
multi-line appends, batch-boundary CR, repeated CR, erase-line,
and recovery paths, and passes on both implementations by design.
Gates: fmt; clippy workspace all-targets; lib 1531; crdt lib 1705;
compile acceptance 60; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2517/0; git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
parent
6793edcfc7
commit
a49adc2589
|
|
@ -318,6 +318,8 @@ local function resync(slot)
|
|||
local len = buf:len()
|
||||
buf:insert(len, DESYNC_MARKER, { bypass_intercept = true })
|
||||
slot.out_pos = buf:len()
|
||||
-- The marker ends with \n, so the fresh epoch starts a new line.
|
||||
slot.line_start = slot.out_pos
|
||||
slot.parse_line_start = slot.out_pos
|
||||
slot.next_row = count_newlines(buf:slice(0, slot.parse_line_start))
|
||||
slot.expected_rev = buf:revision()
|
||||
|
|
@ -383,6 +385,28 @@ local function codepoint_prefix_bytes(s, n)
|
|||
return i
|
||||
end
|
||||
|
||||
-- Track the current line's start as bytes land (round-5 finding 3):
|
||||
-- `slot.line_start` is the byte offset where the line containing
|
||||
-- `out_pos` begins. CR, backspace, and erase-line rewinds read it in
|
||||
-- O(1); the old per-event scan materialized and walked the ENTIRE
|
||||
-- preceding buffer (buf:slice(0, pos)) on every CR — quadratic for a
|
||||
-- progress-heavy command behind megabytes of output. The value
|
||||
-- advances wherever a \n lands (this helper for appended text; the
|
||||
-- mid-line newline branch inline) and resets on the recovery paths
|
||||
-- (run start, resync, raw marker appends). Rewinds never cross it,
|
||||
-- so it is always ≤ out_pos and always a line start.
|
||||
local function note_appended(slot, base, text)
|
||||
local last = nil
|
||||
local search = 1
|
||||
while true do
|
||||
local idx = text:find("\n", search, true)
|
||||
if not idx then break end
|
||||
last = idx
|
||||
search = idx + 1
|
||||
end
|
||||
if last then slot.line_start = base + last end
|
||||
end
|
||||
|
||||
-- Append `text` at the tracked output position with overwrite
|
||||
-- semantics (CR progress bars rewrite the current line in place).
|
||||
--
|
||||
|
|
@ -418,6 +442,7 @@ local function emit_text(slot, text)
|
|||
local rest = text:sub(idx)
|
||||
buf:insert(len, rest, { bypass_intercept = true })
|
||||
slot.out_pos = len + #rest
|
||||
note_appended(slot, len, rest)
|
||||
add_style_span(slot, len, len + #rest)
|
||||
return
|
||||
end
|
||||
|
|
@ -428,6 +453,7 @@ local function emit_text(slot, text)
|
|||
-- over it.
|
||||
buf:insert(len, "\n", { bypass_intercept = true })
|
||||
slot.out_pos = len + 1
|
||||
slot.line_start = len + 1
|
||||
idx = idx + 1
|
||||
else
|
||||
local seg = text:sub(idx, (nl or #text + 1) - 1)
|
||||
|
|
@ -444,27 +470,11 @@ local function emit_text(slot, text)
|
|||
end
|
||||
end
|
||||
|
||||
-- Byte offset where the line containing `out_pos` starts. Scanned
|
||||
-- from the buffer (the REPL's `_current_line_start` discipline) —
|
||||
-- NOT `parse_line_start`, which only advances once per batch: a CR
|
||||
-- The current unterminated line runs from the tracked
|
||||
-- `slot.line_start` (per-slot, updated at every \n — NOT
|
||||
-- `parse_line_start`, which only advances once per batch: a CR
|
||||
-- arriving in the same batch as earlier completed lines must rewind
|
||||
-- to the start of the CURRENT line, not to the batch's first line
|
||||
-- (using the stale value let a progress line overwrite everything
|
||||
-- emitted earlier in the batch).
|
||||
local function current_line_start(slot)
|
||||
local pos = math.min(slot.out_pos, slot.buf:len())
|
||||
local prefix = slot.buf:slice(0, pos)
|
||||
local start = 0
|
||||
local search = 1
|
||||
while true do
|
||||
local idx = prefix:find("\n", search, true)
|
||||
if not idx then return start end
|
||||
start = idx
|
||||
search = idx + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- The current unterminated line runs from its scanned start to
|
||||
-- to the start of the CURRENT line, not the batch's first line) to
|
||||
-- buf:len() — no newline ever exists past out_pos (output is
|
||||
-- append-only except CR/BS rewinds within the current line).
|
||||
local function apply_events(slot, events)
|
||||
|
|
@ -476,12 +486,12 @@ local function apply_events(slot, events)
|
|||
elseif kind == "set_style" then
|
||||
slot.cur_style = ev.style
|
||||
elseif kind == "carriage_return" then
|
||||
slot.out_pos = current_line_start(slot)
|
||||
slot.out_pos = slot.line_start
|
||||
elseif kind == "backspace" then
|
||||
-- Step back over one whole CODEPOINT, not one byte — a
|
||||
-- mid-codepoint out_pos would make the next overwrite split
|
||||
-- the character (round-3 finding 1).
|
||||
local ls = current_line_start(slot)
|
||||
local ls = slot.line_start
|
||||
if slot.out_pos > ls then
|
||||
local prefix = slot.buf:slice(ls, slot.out_pos)
|
||||
local i = #prefix
|
||||
|
|
@ -496,7 +506,7 @@ local function apply_events(slot, events)
|
|||
buf:delete(slot.out_pos, len, { bypass_intercept = true })
|
||||
end
|
||||
elseif kind == "erase_line" then
|
||||
local ls = current_line_start(slot)
|
||||
local ls = slot.line_start
|
||||
local len = buf:len()
|
||||
if ls < len then
|
||||
buf:delete(ls, len, { bypass_intercept = true })
|
||||
|
|
@ -628,8 +638,10 @@ end
|
|||
-- pump cleanup/forget ran (round-1 finding 5).
|
||||
local function emit_text_raw(slot, text)
|
||||
local buf = slot.buf
|
||||
buf:insert(buf:len(), text, { bypass_intercept = true })
|
||||
local base = buf:len()
|
||||
buf:insert(base, text, { bypass_intercept = true })
|
||||
slot.out_pos = buf:len()
|
||||
note_appended(slot, base, text)
|
||||
if slot.parse_line_start > slot.out_pos then
|
||||
slot.parse_line_start = slot.out_pos
|
||||
end
|
||||
|
|
@ -764,6 +776,8 @@ local function start_run(slot, cmdline, opts)
|
|||
local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(unknown)")
|
||||
buf:insert(0, header, { bypass_intercept = true })
|
||||
slot.out_pos = buf:len()
|
||||
-- The header ends with \n, so output starts on a fresh line.
|
||||
slot.line_start = slot.out_pos
|
||||
slot.parse_line_start = slot.out_pos
|
||||
slot.next_row = count_newlines(header)
|
||||
slot.expected_rev = buf:revision()
|
||||
|
|
@ -786,8 +800,12 @@ local function start_run(slot, cmdline, opts)
|
|||
}
|
||||
if cwd then spec.cwd = cwd end
|
||||
local ok, proc = pcall(pmacs.process.spawn, spec)
|
||||
-- switch_buffer synchronously fires buffer.after-switch, whose
|
||||
-- subscription above attaches the overlay — a second explicit
|
||||
-- attach here stacked a duplicate render view per run (round-5
|
||||
-- finding 1; translation itself is buffer-level and unaffected by
|
||||
-- attachment count).
|
||||
pmacs.window.switch_buffer(slot.buf)
|
||||
pcall(pmacs.buffer.attach_style_overlay, slot.buf, slot.overlay)
|
||||
if not ok then
|
||||
emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc)))
|
||||
slot.expected_rev = buf:revision()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,31 @@
|
|||
# Compile-mode — framing (Arc 5 stage 1, terminal)
|
||||
|
||||
**Revision 10 — 2026-07-13. Status: implemented on branch
|
||||
`compile-mode` (PR #113); revisions 7–10 fold in PR rounds 1–4.**
|
||||
**Revision 11 — 2026-07-13. Status: implemented on branch
|
||||
`compile-mode` (PR #113); revisions 7–11 fold in PR rounds 1–5.**
|
||||
|
||||
Revision 11 (PR #113 round 5, findings 1–3): style-span coordinate
|
||||
translation belongs to the BUFFER, not to views — a new
|
||||
`BufferStyleSpanTranslator` is attached to the buffer by
|
||||
`pmacs.buffer.add_style_overlay`, sees every edit exactly once
|
||||
(bypass writes, undo/redo, remote CRDT ops) regardless of window
|
||||
count or visibility, and the window-attached `BufferStyleOverlay`
|
||||
copies are render-only. Pre-fix each attached view translated the
|
||||
shared store from its own `on_edit`: the duplicate attachment in
|
||||
`start_run` (switch_buffer's after-switch hook already attaches)
|
||||
made byte-delta rewrites shift later spans TWICE on the normal path,
|
||||
splits multiplied further, and a hidden buffer shifted ZERO times.
|
||||
Translation also preserves the untouched fragments of a partially
|
||||
overlapped span (finding 2): left of the replaced range keeps its
|
||||
styling, right of it shifts by the length delta, and only the bytes
|
||||
actually rewritten lose theirs — `red abc, reset, CR, X` renders a
|
||||
default X followed by red `bc`, where the old translation dropped
|
||||
any overlapping span whole. Per-cell rendered assertions
|
||||
(glyph, fg) pin both — `any_styled_cell` cannot see a wrong color on
|
||||
the right cell. And the per-event whole-prefix line-start scan is
|
||||
gone (finding 3): `slot.line_start` is tracked — advanced at every
|
||||
\n, reset on recovery paths — making CR/BS/erase-line O(1); measured
|
||||
2.52s → 0.67s on 2 MB + 3000 CRs (the pin is behavioral, not timed —
|
||||
timing bounds flake on slow CI).
|
||||
|
||||
Revision 10 (PR #113 round 4, findings 1–2): CR rewrites are
|
||||
COLUMN-counted and newline-segmented, not byte-counted — each
|
||||
|
|
@ -211,10 +235,13 @@ Everything below was verified by reading the code, not the roadmap.
|
|||
teardown point calling `pmacs.process.forget` only after the
|
||||
terminal event (`:782-822`, the M6.9 no-leak discipline).
|
||||
- **Style overlay window semantics** (`src/lua_bindings/mod.rs:
|
||||
2907-2933`, `:1756-1799`): `add_style_overlay` attaches only to
|
||||
windows *currently showing* the buffer; buffer switches clear
|
||||
window overlays; `attach_style_overlay(buf, handle)` re-attaches.
|
||||
The handle has `add`, `clear`, `clear_before`, `spans`.
|
||||
2907-2933`, `:1756-1799`): `add_style_overlay` attaches a
|
||||
buffer-level `BufferStyleSpanTranslator` (coordinate translation,
|
||||
exactly once per edit — Revision 11) plus render-only window
|
||||
overlays on windows *currently showing* the buffer; buffer
|
||||
switches clear window overlays; `attach_style_overlay(buf,
|
||||
handle)` re-attaches the render view. The handle has `add`,
|
||||
`clear`, `clear_before`, `spans`.
|
||||
- **Buffer-switch hooks**: `buffer.after-switch` exists and fires on
|
||||
the ordinary switch paths (recentf subscribes,
|
||||
`builtin/runtime/recentf.lua:54`). **`pmacs.editor.jump_back` does
|
||||
|
|
@ -604,14 +631,17 @@ invoke time, so commands/runtime load order stays irrelevant for it.
|
|||
raises.
|
||||
- **Style overlay discipline:** one overlay handle per generated
|
||||
buffer, created once and retained; `overlay:clear()` on each run
|
||||
reset; `pmacs.buffer.attach_style_overlay(buf, handle)` after
|
||||
every switch the module performs into the buffer, **and (Revision
|
||||
3) from a `buffer.after-switch` subscription that re-attaches
|
||||
whenever any switch path lands on one of its buffers** — combined
|
||||
with the jump-back parity fix (additions #3), this covers `C-x b`
|
||||
returns and the primary RET → `M-,` workflow. The former
|
||||
"user-initiated switch loses styling" deferral is withdrawn as
|
||||
covered.
|
||||
reset; render re-attach rides **one** `buffer.after-switch`
|
||||
subscription that fires whenever any switch path lands on one of
|
||||
its buffers (Revision 3) — `start_run`'s own switch included, so
|
||||
the former explicit attach after it stacked a duplicate render
|
||||
view per run and is removed (Revision 11) — combined with the
|
||||
jump-back parity fix (additions #3), this covers `C-x b` returns
|
||||
and the primary RET → `M-,` workflow. The former "user-initiated
|
||||
switch loses styling" deferral is withdrawn as covered.
|
||||
Coordinate translation never depends on any of this: it lives on
|
||||
the buffer (Revision 11), exactly once per edit, hidden or split
|
||||
or not attached at all.
|
||||
- No marks needed: one append point, tracked as plain integers, with
|
||||
the revision guard above as the honesty check.
|
||||
|
||||
|
|
|
|||
|
|
@ -2916,6 +2916,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
}
|
||||
|
||||
{
|
||||
let reg = registry.clone();
|
||||
buffer.set(
|
||||
"add_style_overlay",
|
||||
lua.create_function(
|
||||
|
|
@ -2924,6 +2925,21 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
let handle = StyleOverlayHandleLua {
|
||||
spans: Arc::clone(&spans),
|
||||
};
|
||||
// Coordinate translation lives on the BUFFER
|
||||
// (PR #113 round-5 finding 1): buffer-attached
|
||||
// views see every edit exactly once — Lua bypass
|
||||
// writes, undo/redo, remote CRDT ops — whether or
|
||||
// not any window shows the buffer. The window
|
||||
// attachments below are render-only; per-window
|
||||
// translation ran once per split and zero times
|
||||
// hidden.
|
||||
{
|
||||
let mut r = reg.borrow_mut();
|
||||
let buf = resolve_mut(&mut r, id.0)?;
|
||||
buf.attach_view(Box::new(crate::overlay::BufferStyleSpanTranslator::new(
|
||||
Arc::clone(&spans),
|
||||
)));
|
||||
}
|
||||
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
|
||||
Ok(handle)
|
||||
},
|
||||
|
|
|
|||
209
src/overlay.rs
209
src/overlay.rs
|
|
@ -186,6 +186,14 @@ pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
|
|||
/// than viewport cell ranges. That is the right shape for stream
|
||||
/// consumers such as the REPL: ANSI SGR applies to bytes as they land
|
||||
/// in the rope, and render maps the surviving ranges into visible cells.
|
||||
///
|
||||
/// RENDER-ONLY (PR #113 round-5 finding 1): this view deliberately
|
||||
/// does not implement `on_edit`. Every window showing the buffer
|
||||
/// holds its own copy over the SAME shared store, so a per-view
|
||||
/// translation runs once per attached window — twice under a split,
|
||||
/// zero times while the buffer is hidden. Coordinate translation
|
||||
/// belongs to [`BufferStyleSpanTranslator`], attached to the buffer
|
||||
/// itself.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BufferStyleOverlay {
|
||||
spans: SharedBufferStyleSpans,
|
||||
|
|
@ -199,7 +207,35 @@ impl BufferStyleOverlay {
|
|||
}
|
||||
}
|
||||
|
||||
impl View for BufferStyleOverlay {
|
||||
/// Buffer-attached edit translator for a shared span store.
|
||||
///
|
||||
/// Keeps the byte coordinates in a [`SharedBufferStyleSpans`] store
|
||||
/// in sync with buffer edits, EXACTLY ONCE per edit, independent of
|
||||
/// how many windows currently render the buffer (PR #113 round-5
|
||||
/// finding 1). Buffer-attached views receive `on_edit` on every
|
||||
/// mutation path — intercept-skipping Lua writes, undo/redo, and
|
||||
/// remote CRDT ops — whether or not the buffer is displayed
|
||||
/// anywhere; window-attached [`BufferStyleOverlay`] copies are
|
||||
/// render-only.
|
||||
///
|
||||
/// Translation preserves the untouched fragments of a span that
|
||||
/// partially overlaps the edit (round-5 finding 2): bytes before the
|
||||
/// replaced range keep their styling, bytes at/after it keep theirs
|
||||
/// shifted by the edit's length delta, and only the bytes actually
|
||||
/// replaced lose styling — the writer styles what it writes.
|
||||
pub struct BufferStyleSpanTranslator {
|
||||
spans: SharedBufferStyleSpans,
|
||||
}
|
||||
|
||||
impl BufferStyleSpanTranslator {
|
||||
/// Construct a translator over `spans`.
|
||||
#[must_use]
|
||||
pub fn new(spans: SharedBufferStyleSpans) -> Self {
|
||||
Self { spans }
|
||||
}
|
||||
}
|
||||
|
||||
impl View for BufferStyleSpanTranslator {
|
||||
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), crate::buffer::BufferError> {
|
||||
let old_start = edit.range.start;
|
||||
let old_end = edit.range.end;
|
||||
|
|
@ -207,22 +243,39 @@ impl View for BufferStyleOverlay {
|
|||
let new_len = edit.inserted_len;
|
||||
let mut spans = self.spans.lock().expect("style spans mutex poisoned");
|
||||
let mut adjusted = Vec::with_capacity(spans.len());
|
||||
for mut span in spans.drain(..) {
|
||||
if span.end <= old_start {
|
||||
adjusted.push(span);
|
||||
} else if span.start >= old_end {
|
||||
span.start = shift_pos(span.start, old_end, old_len, new_len);
|
||||
span.end = shift_pos(span.end, old_end, old_len, new_len);
|
||||
adjusted.push(span);
|
||||
for span in spans.drain(..) {
|
||||
// Left fragment: bytes strictly before the replaced
|
||||
// range are untouched by the edit.
|
||||
if span.start < old_start {
|
||||
adjusted.push(BufferStyleSpan {
|
||||
start: span.start,
|
||||
end: span.end.min(old_start),
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
// Overlapping spans are dropped. REPL style spans are append-only
|
||||
// and scrollback truncation deletes whole old blocks, so a
|
||||
// conservative drop is simpler and avoids half-styled fragments.
|
||||
// Right fragment: bytes at/after the replaced range
|
||||
// survive, shifted by the length delta. (`pos >= old_end
|
||||
// >= old_len`, so the subtraction cannot underflow.)
|
||||
if span.end > old_end {
|
||||
adjusted.push(BufferStyleSpan {
|
||||
start: span.start.max(old_end) - old_len + new_len,
|
||||
end: span.end - old_len + new_len,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
// A span entirely inside the replaced range produces
|
||||
// neither fragment and is dropped.
|
||||
}
|
||||
*spans = adjusted;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind(&self) -> &'static str {
|
||||
"buffer_style_span_translator"
|
||||
}
|
||||
}
|
||||
|
||||
impl View for BufferStyleOverlay {
|
||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
let spans = self
|
||||
.spans
|
||||
|
|
@ -243,15 +296,6 @@ impl View for BufferStyleOverlay {
|
|||
}
|
||||
}
|
||||
|
||||
fn shift_pos(pos: u64, old_end: u64, old_len: u64, new_len: u64) -> u64 {
|
||||
if new_len >= old_len {
|
||||
pos + (new_len - old_len)
|
||||
} else {
|
||||
pos.saturating_sub(old_end)
|
||||
.saturating_add(old_end - (old_len - new_len))
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_line_offsets(buf: &Buffer) -> Vec<u64> {
|
||||
let mut offsets = vec![0];
|
||||
let rope = buf.snapshot_rope();
|
||||
|
|
@ -681,4 +725,129 @@ mod tests {
|
|||
});
|
||||
virt.render(&buf, viewport(1, 5), &mut grid);
|
||||
}
|
||||
|
||||
fn red() -> Style {
|
||||
Style {
|
||||
fg: crate::cell::Color::Indexed(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn spans_of(store: &SharedBufferStyleSpans) -> Vec<(u64, u64)> {
|
||||
store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| (s.start, s.end))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translator_shifts_spans_exactly_once_regardless_of_render_views() {
|
||||
// PR #113 round-5 finding 1: N windows over the same store
|
||||
// must not translate N times, and zero windows must not mean
|
||||
// zero translations. The render-only overlays contribute
|
||||
// nothing to on_edit; the single buffer-attached translator
|
||||
// does it all.
|
||||
use crate::buffer::EditOp;
|
||||
use crate::rope::Range;
|
||||
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abc");
|
||||
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||
start: 1,
|
||||
end: 3,
|
||||
style: red(),
|
||||
}]));
|
||||
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||
// Two render copies attached to the same buffer — the
|
||||
// split-window shape. Their on_edit is the default no-op.
|
||||
buf.attach_view(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
|
||||
buf.attach_view(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
|
||||
// Replace byte 0 with two bytes: delta +1, span after the
|
||||
// edit shifts by exactly one.
|
||||
buf.apply_edit(EditOp::Replace {
|
||||
range: Range::new(0, 1),
|
||||
bytes: b"XY",
|
||||
})
|
||||
.expect("edit applies");
|
||||
assert_eq!(
|
||||
spans_of(&store),
|
||||
vec![(2, 4)],
|
||||
"one translator, one shift — attachment count is irrelevant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translator_preserves_untouched_span_fragments() {
|
||||
// PR #113 round-5 finding 2: a partial overwrite must keep
|
||||
// styling on the bytes it never wrote. Replacing byte 0 of a
|
||||
// red [0,3) span (same length) leaves [1,3) red; the written
|
||||
// byte's styling is the writer's business.
|
||||
use crate::buffer::EditOp;
|
||||
use crate::rope::Range;
|
||||
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abc");
|
||||
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||
start: 0,
|
||||
end: 3,
|
||||
style: red(),
|
||||
}]));
|
||||
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||
buf.apply_edit(EditOp::Replace {
|
||||
range: Range::new(0, 1),
|
||||
bytes: b"X",
|
||||
})
|
||||
.expect("edit applies");
|
||||
assert_eq!(
|
||||
spans_of(&store),
|
||||
vec![(1, 3)],
|
||||
"the untouched right fragment survives a same-length rewrite"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translator_splits_a_span_around_an_interior_edit() {
|
||||
// Both fragments survive an interior replacement; the
|
||||
// replaced middle loses styling. Also pins the insertion
|
||||
// case: bytes inserted INSIDE a span do not inherit style.
|
||||
use crate::buffer::EditOp;
|
||||
use crate::rope::Range;
|
||||
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abcdef");
|
||||
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||
start: 0,
|
||||
end: 6,
|
||||
style: red(),
|
||||
}]));
|
||||
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||
// Replace "cd" with "Z": left [0,2) intact, right [4,6)
|
||||
// shifts to [3,5).
|
||||
buf.apply_edit(EditOp::Replace {
|
||||
range: Range::new(2, 4),
|
||||
bytes: b"Z",
|
||||
})
|
||||
.expect("edit applies");
|
||||
assert_eq!(
|
||||
spans_of(&store),
|
||||
vec![(0, 2), (3, 5)],
|
||||
"left kept, right shifted by the length delta"
|
||||
);
|
||||
// A span wholly inside a replaced range is dropped.
|
||||
{
|
||||
let mut spans = store.lock().unwrap();
|
||||
spans.clear();
|
||||
spans.push(BufferStyleSpan {
|
||||
start: 1,
|
||||
end: 2,
|
||||
style: red(),
|
||||
});
|
||||
}
|
||||
buf.apply_edit(EditOp::Replace {
|
||||
range: Range::new(0, 4),
|
||||
bytes: b"....",
|
||||
})
|
||||
.expect("edit applies");
|
||||
assert_eq!(
|
||||
spans_of(&store),
|
||||
Vec::<(u64, u64)>::new(),
|
||||
"a fully-overwritten span produces no fragments"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,35 @@ fn any_styled_cell(cells: &[pmacs::cell::Cell]) -> bool {
|
|||
.any(|c| c.style != pmacs::cell::Style::default())
|
||||
}
|
||||
|
||||
/// `(glyph, fg)` for the first `n` cells of the grid row whose glyphs
|
||||
/// spell `prefix`; panics if no row matches. Round-5 tests pin exact
|
||||
/// per-cell colors — `any_styled_cell` cannot see a wrong color on
|
||||
/// the right glyph.
|
||||
fn styled_row(
|
||||
cells: &[pmacs::cell::Cell],
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
prefix: &str,
|
||||
n: usize,
|
||||
) -> Vec<(char, pmacs::cell::Color)> {
|
||||
let glyph_at = |r: u32, c: u32| match cells[(r * cols + c) as usize].glyph {
|
||||
pmacs::cell::Glyph::Char(ch) => ch,
|
||||
_ => ' ',
|
||||
};
|
||||
for r in 0..rows {
|
||||
let line: String = (0..cols).map(|c| glyph_at(r, c)).collect();
|
||||
if line.starts_with(prefix) {
|
||||
return (0..n)
|
||||
.map(|i| {
|
||||
let cell = &cells[(r * cols) as usize + i];
|
||||
(glyph_at(r, i as u32), cell.style.fg)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
panic!("no rendered row starts with {prefix:?}");
|
||||
}
|
||||
|
||||
const DESYNC: &str = "[output desynced by external edit]";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2212,3 +2241,154 @@ fn r4f2_alt_screen_style_desync_resynced_on_exit_and_finish() {
|
|||
though the internal style is already default"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PR #113 round 5 — bite tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn r5f1_span_translation_is_exactly_once_for_the_active_buffer() {
|
||||
use pmacs::cell::Color;
|
||||
// Red 'a', blue 'bc', then CR and a red 2-byte é overwriting the
|
||||
// 'a' (length delta +1): the blue span must shift exactly ONCE
|
||||
// (round-5 finding 1). Pre-fix every attached view translated
|
||||
// the shared store — the duplicate attachment made it twice on
|
||||
// the normal path, splits multiplied it further — leaving 'b'
|
||||
// unstyled and the span end past the buffer.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = write_script(
|
||||
dir.path(),
|
||||
"spans.sh",
|
||||
"printf '\\033[31ma\\033[34mbc\\r\\033[31m\\303\\251\\n'\n",
|
||||
);
|
||||
let mut s = editor();
|
||||
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||
let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3);
|
||||
assert_eq!(
|
||||
row,
|
||||
vec![
|
||||
('\u{e9}', Color::Indexed(1)),
|
||||
('b', Color::Indexed(4)),
|
||||
('c', Color::Indexed(4)),
|
||||
],
|
||||
"the blue span shifts exactly once past the 1-to-2-byte rewrite"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r5f1_hidden_buffer_spans_still_translate() {
|
||||
use pmacs::cell::Color;
|
||||
// The compile buffer sits in NO window while the rewrite
|
||||
// arrives: pre-fix, no view received on_edit and the spans were
|
||||
// never adjusted at all. Translation is buffer-level now,
|
||||
// independent of visibility.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = write_script(
|
||||
dir.path(),
|
||||
"hidden.sh",
|
||||
concat!(
|
||||
"printf '\\033[31ma\\033[34mbc'\n",
|
||||
"sleep 0.5\n",
|
||||
"printf '\\r\\033[31m\\303\\251\\n'\n",
|
||||
),
|
||||
);
|
||||
let mut s = editor();
|
||||
compile_run(&s, &format!("sh {script}"), dir.path());
|
||||
assert!(
|
||||
pump_until(&mut s, 5_000, |s| compilation_text(s).contains("abc")),
|
||||
"styled prefix lands first"
|
||||
);
|
||||
exec(
|
||||
&s,
|
||||
r#"pmacs.window.switch_buffer(pmacs.buffer.create("*elsewhere*"))"#,
|
||||
);
|
||||
assert!(
|
||||
pump_until(&mut s, 10_000, |s| named_text(s, "*compilation*")
|
||||
.contains("[compile ")),
|
||||
"run finishes while hidden"
|
||||
);
|
||||
// Switch back; the after-switch hook re-attaches the render view.
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
if pmacs.describe.buffer(id).name == "*compilation*" then
|
||||
pmacs.window.switch_buffer(id)
|
||||
end
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||
let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3);
|
||||
assert_eq!(
|
||||
row,
|
||||
vec![
|
||||
('\u{e9}', Color::Indexed(1)),
|
||||
('b', Color::Indexed(4)),
|
||||
('c', Color::Indexed(4)),
|
||||
],
|
||||
"spans shifted while hidden; the re-shown buffer renders true colors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r5f2_partial_rewrite_preserves_untouched_styling() {
|
||||
use pmacs::cell::Color;
|
||||
// SGR red; abc; SGR reset; CR; X — the default-styled X
|
||||
// overwrites only 'a'; 'bc' keeps its red (round-5 finding 2).
|
||||
// Pre-fix any overlap dropped the WHOLE span (bc lost its
|
||||
// color); with no translation at all the stale span painted the
|
||||
// default X red instead. Exact per-cell colors on both sides.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = write_script(
|
||||
dir.path(),
|
||||
"frag.sh",
|
||||
"printf '\\033[31mabc\\033[0m\\rX\\n'\n",
|
||||
);
|
||||
let mut s = editor();
|
||||
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||
let row = styled_row(&cells, 12, 60, "Xbc", 3);
|
||||
assert_eq!(
|
||||
row,
|
||||
vec![
|
||||
('X', Color::Default),
|
||||
('b', Color::Indexed(1)),
|
||||
('c', Color::Indexed(1)),
|
||||
],
|
||||
"the rewritten cell is default-styled; the untouched suffix keeps red"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
|
||||
// Round-5 finding 3 is a performance fix — the per-CR/BS/erase
|
||||
// whole-prefix scan became a tracked byte. These are the
|
||||
// correctness pins for that tracked value across every
|
||||
// transition: a multi-line append, CR after a batch boundary,
|
||||
// repeated CR on one line, erase-line, and a fresh line after
|
||||
// each. Behavior must be identical to the old scan (this test
|
||||
// passes on both by design — the perf win is measured, not
|
||||
// asserted; a timing bound here would flake on slow CI).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = write_script(
|
||||
dir.path(),
|
||||
"track.sh",
|
||||
concat!(
|
||||
"printf 'l1\\nl2 partial'\n",
|
||||
"sleep 0.3\n",
|
||||
"printf '\\rL2 done!!!\\n'\n",
|
||||
"printf 'p 1\\rp 2\\rp 22\\n'\n",
|
||||
"printf 'erase me\\033[2K'\n",
|
||||
"printf 'clean\\n'\n",
|
||||
),
|
||||
);
|
||||
let mut s = editor();
|
||||
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||
let text = compilation_text(&s);
|
||||
assert!(
|
||||
text.contains("\nl1\nL2 done!!!\np 22\nclean\n"),
|
||||
"every rewind lands at the tracked line start; buffer:\n{text:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue