From 30b8c8d8d8856aea61331b2fe26d170d1b6c2884 Mon Sep 17 00:00:00 2001 From: Subin An Date: Tue, 28 Apr 2026 10:08:21 +0900 Subject: [PATCH 01/51] feat(buffer): #231 Buffer::snapshot_format() stable styled snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a stable, deterministic serializer for the render buffer that captures both text and styles in a format compatible with `insta::assert_snapshot!`. Default-style cells emit raw text; non-default runs are wrapped as `[fg=...,bg=...,mods]"text"[/]`. Named palette colors use short codes (`red`, `light_blue`); RGB → `#rrggbb`; indexed palette → `idx`. Modifiers emitted in canonical order (bold, dim, italic, underline, reversed, strikethrough) so two equivalent values always serialize identically. Format pinned by `tests/snapshot_format_stability.rs` with 9 hand-crafted scenarios (default-only, color runs, modifier ordering, fg+bg, indexed, RGB, escape handling, determinism, wide-char trailing). Hand-rolled formatter for `Color` / `Modifiers` so upstream `Debug` derive changes cannot silently break locked snapshots. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/buffer.rs | 316 +++++++++++++++++++++++++++++ tests/snapshot_format_stability.rs | 113 +++++++++++ 2 files changed, 429 insertions(+) create mode 100644 tests/snapshot_format_stability.rs diff --git a/src/buffer.rs b/src/buffer.rs index bdec24b..5f52e12 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -480,6 +480,207 @@ impl Buffer { self.content.resize(size, Cell::default()); self.reset(); } + + /// Serialize the buffer into a stable, styled-snapshot format suitable for + /// snapshot testing (e.g. with `insta::assert_snapshot!`). + /// + /// # Format + /// + /// One line per buffer row, joined with `\n`. Within a row, runs of cells + /// that share an identical [`Style`] are grouped. The default style (no + /// foreground, no background, no modifiers) emits **unannotated** text — + /// no `[...]` markers. Any non-default run is wrapped: + /// + /// ```text + /// [fg=...,bg=...,mods]"text"[/] + /// ``` + /// + /// Trailing whitespace per row is preserved in the styled segment but + /// trailing default-style spaces at the end of a row are emitted verbatim + /// (they are visually invisible in diffs). Empty cells render as a single + /// space. The terminating `[/]` marker only appears when a styled run is + /// in effect at the end of a row. + /// + /// # Color formatting + /// + /// Named palette colors use short lowercase codes: + /// `reset`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, + /// `white`, `dark_gray`, `light_red`, `light_green`, `light_yellow`, + /// `light_blue`, `light_magenta`, `light_cyan`, `light_white`. RGB colors + /// emit `#rrggbb`. Indexed palette colors emit `idx` (decimal). + /// + /// # Modifier formatting + /// + /// Modifiers are emitted as comma-separated lowercase tokens in a fixed + /// canonical order: `bold`, `dim`, `italic`, `underline`, `reversed`, + /// `strikethrough`. Order is independent of the bit pattern, so two + /// equivalent `Modifiers` values always serialize identically. + /// + /// # Stability + /// + /// The output format is stable across patch and minor versions of SLT. + /// Names use a hand-rolled formatter (not `Debug`) so derives changing + /// upstream cannot accidentally break locked snapshots. A breaking change + /// to the format would be reserved for a major version bump. + /// + /// # Determinism + /// + /// Identical input buffers always produce byte-equal output. This is a + /// hard requirement — snapshot tests rely on it. + /// + /// # Example + /// + /// ``` + /// use slt::{Buffer, Color, Rect, Style}; + /// + /// let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1)); + /// buf.set_string(0, 0, "ab", Style::new().fg(Color::Red).bold()); + /// buf.set_string(2, 0, "cd", Style::new()); + /// let snap = buf.snapshot_format(); + /// assert!(snap.starts_with("[fg=red,bold]\"ab\"[/]cd")); + /// ``` + pub fn snapshot_format(&self) -> String { + let mut out = String::new(); + let width = self.area.width; + let height = self.area.height; + if width == 0 || height == 0 { + return out; + } + + for y in self.area.y..self.area.bottom() { + if y > self.area.y { + out.push('\n'); + } + + // Walk the row, grouping consecutive cells by Style. + let mut current_style: Option