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:
Levi Neuwirth 2026-07-13 22:07:01 +01:00
parent 6793edcfc7
commit a49adc2589
5 changed files with 472 additions and 59 deletions

View File

@ -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()

View File

@ -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 710 fold in PR rounds 14.**
**Revision 11 — 2026-07-13. Status: implemented on branch
`compile-mode` (PR #113); revisions 711 fold in PR rounds 15.**
Revision 11 (PR #113 round 5, findings 13): 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 12): 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.

View File

@ -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)
},

View File

@ -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"
);
}
}

View File

@ -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:?}"
);
}