From a49adc25894de09029e82bdd2f46871f144c9105 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 13 Jul 2026 22:07:01 +0100 Subject: [PATCH] =?UTF-8?q?fix(compile):=20PR=20#113=20round=205=20?= =?UTF-8?q?=E2=80=94=20buffer-level=20span=20translation,=20fragment=20pre?= =?UTF-8?q?servation,=20tracked=20line=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- builtin/runtime/compile.lua | 68 ++++++---- docs/compile-mode-framing.md | 58 ++++++--- src/lua_bindings/mod.rs | 16 +++ src/overlay.rs | 209 ++++++++++++++++++++++++++++--- tests/compile_mode_acceptance.rs | 180 ++++++++++++++++++++++++++ 5 files changed, 472 insertions(+), 59 deletions(-) diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 56ec2d1..e364de2 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -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() diff --git a/docs/compile-mode-framing.md b/docs/compile-mode-framing.md index f093783..f5b7f7c 100644 --- a/docs/compile-mode-framing.md +++ b/docs/compile-mode-framing.md @@ -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. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 5f992b6..2501c9e 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -2916,6 +2916,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result>>; /// 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 { 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" + ); + } } diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 2cb689c..9a37c76 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -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:?}" + ); +}