Skip to main content

rich_ext/
testing.rs

1//! Deterministic snapshots for downstream render regression tests.
2#[cfg(feature = "syntax")]
3pub mod conformance;
4
5use crate::target::RenderTarget;
6use rich::protocol::RenderEnvironment;
7use rich::{Renderable, Segment};
8use serde::{Deserialize, Serialize};
9
10/// Owned segment data; attributes follow core SGR order, with `not ` for explicit off.
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SnapshotSegment {
13    pub text: String,
14    pub control: bool,
15    pub foreground: Option<String>,
16    pub background: Option<String>,
17    pub attributes: Vec<String>,
18    pub link: Option<String>,
19}
20/// A run of text in one style on one row, as schema 2 stores it: adjacent
21/// segments that look the same are merged, so how the output was split into
22/// segments does not show.
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct SnapshotRun {
25    pub text: String,
26    pub foreground: Option<String>,
27    pub background: Option<String>,
28    pub attributes: Vec<String>,
29    pub link: Option<String>,
30}
31impl SnapshotRun {
32    fn same_style(&self, other: &SnapshotRun) -> bool {
33        (
34            &self.foreground,
35            &self.background,
36            &self.attributes,
37            &self.link,
38        ) == (
39            &other.foreground,
40            &other.background,
41            &other.attributes,
42            &other.link,
43        )
44    }
45}
46/// A semantic region, as schema 3 stores it (see
47/// [`crate::frame::Region`]): its role name, the role's numbers, label, link,
48/// parent (an index into the same list) and spans as `[row, start, end]`
49/// column ranges.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SnapshotRegion {
52    pub role: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub level: Option<u8>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub row: Option<usize>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub column: Option<usize>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub label: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub link: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub parent: Option<usize>,
65    pub spans: Vec<[usize; 3]>,
66}
67impl SnapshotRegion {
68    fn from_region(region: &crate::frame::Region) -> Self {
69        use rich::protocol::RegionRole;
70        let (level, row, column) = match region.role {
71            RegionRole::Heading { level } => (Some(level), None, None),
72            RegionRole::TableCell { row, column } => (None, Some(row), Some(column)),
73            RegionRole::TableHeader { column } | RegionRole::TableFooter { column } => {
74                (None, None, Some(column))
75            }
76            _ => (None, None, None),
77        };
78        SnapshotRegion {
79            role: crate::frame::role_name(&region.role),
80            level,
81            row,
82            column,
83            label: region.label.clone(),
84            link: region.link.clone(),
85            parent: region.parent,
86            spans: region
87                .spans
88                .iter()
89                .map(|span| [span.row, span.columns.start, span.columns.end])
90                .collect(),
91        }
92    }
93}
94/// A render captured for comparison.
95///
96/// Schema 1 ([`RenderSnapshot::capture`]) stores the segments as rendered.
97/// Schema 2 ([`RenderSnapshot::capture_frame`]) stores `rows` of merged runs
98/// instead, drops control segments, and keeps `ansi` in the merged encoding;
99/// its `segments` is empty. Schema 3 ([`RenderSnapshot::capture_regions`])
100/// is schema 2 plus the frame's semantic `regions`. [`RenderSnapshot::diff`]
101/// compares a schema 2 or 3 snapshot with any schema by what shows, and
102/// compares regions when both snapshots have them. Every schema reads back.
103#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104pub struct RenderSnapshot {
105    pub schema_version: u32,
106    pub width: usize,
107    pub height: usize,
108    pub plain: String,
109    pub ansi: String,
110    pub segments: Vec<SnapshotSegment>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub rows: Option<Vec<Vec<SnapshotRun>>>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub regions: Option<Vec<SnapshotRegion>>,
115}
116pub type SnapshotError = serde_json::Error;
117impl RenderSnapshot {
118    /// A schema 2 snapshot, built from the render's [`Frame`](crate::frame::Frame).
119    pub fn capture_frame(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
120        let segments = target.segments(renderable);
121        let frame = crate::frame::Frame::from_segments(&segments);
122        Self::from_frame(target, &segments, &frame)
123    }
124    /// Schema 2 fields from one render: its segments and the frame built
125    /// from them.
126    fn from_frame(
127        target: &RenderTarget,
128        segments: &[Segment],
129        frame: &crate::frame::Frame,
130    ) -> Self {
131        let caps = target.capabilities();
132        let snapshot: Vec<SnapshotSegment> = segments.iter().map(snapshot_segment).collect();
133        Self {
134            schema_version: 2,
135            width: caps.width,
136            height: caps.height,
137            plain: frame.plain(),
138            ansi: frame.to_ansi_merged(&target.console()),
139            segments: Vec::new(),
140            rows: Some(rows(&snapshot)),
141            regions: None,
142        }
143    }
144    /// A schema 3 snapshot: schema 2 plus the regions the renderables
145    /// reported (see [`RenderTarget::frame_with_regions`]) and the links.
146    /// Every field comes from one render, so the regions describe exactly
147    /// the stored rows.
148    pub fn capture_regions(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
149        let (segments, frame) = target.segments_and_frame_with_regions(renderable);
150        let regions = frame
151            .regions()
152            .iter()
153            .map(SnapshotRegion::from_region)
154            .collect();
155        Self {
156            schema_version: 3,
157            regions: Some(regions),
158            ..Self::from_frame(target, &segments, &frame)
159        }
160    }
161    /// This snapshot as schema 2: rows of merged runs from its segments. A
162    /// schema 2 snapshot is returned unchanged. `ansi` is kept as captured.
163    #[must_use]
164    pub fn upgrade(&self) -> Self {
165        if self.rows.is_some() {
166            return self.clone();
167        }
168        Self {
169            schema_version: 2,
170            segments: Vec::new(),
171            rows: Some(rows(&self.segments)),
172            ..self.clone()
173        }
174    }
175    pub fn capture(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
176        let segments = target.segments(renderable);
177        let caps = target.capabilities();
178        Self {
179            schema_version: 1,
180            width: caps.width,
181            height: caps.height,
182            plain: segments
183                .iter()
184                .filter(|s| !s.control)
185                .map(|s| s.text.as_str())
186                .collect(),
187            ansi: target.console().segments_to_string(&segments),
188            segments: segments.iter().map(snapshot_segment).collect(),
189            rows: None,
190            regions: None,
191        }
192    }
193    pub fn to_json(&self) -> Result<String, SnapshotError> {
194        serde_json::to_string_pretty(self)
195    }
196    /// How `other` differs, or `None` when equal, without hiding style-only
197    /// changes: a `diff -u` of the plain text when it differs, else the
198    /// style-changed lines and the first differing segment field, else the
199    /// first differing metadata path.
200    ///
201    /// When either snapshot is schema 2 or 3, both are compared as schema 2:
202    /// size, plain text and rows, not `ansi` or how the text was segmented;
203    /// and regions too when both have them.
204    pub fn diff(&self, other: &Self) -> Option<String> {
205        if self.rows.is_some() || other.rows.is_some() {
206            let (a, b) = (self.upgrade(), other.upgrade());
207            let regions_differ = matches!(
208                (&a.regions, &b.regions),
209                (Some(x), Some(y)) if x != y
210            );
211            if (a.width, a.height, &a.plain, &a.rows) == (b.width, b.height, &b.plain, &b.rows)
212                && !regions_differ
213            {
214                return None;
215            }
216            if a.plain != b.plain {
217                return Some(
218                    crate::diff::TextDiff::new(&a.plain, &b.plain).unified("self", "other"),
219                );
220            }
221            let (a_value, b_value) = (
222                serde_json::to_value(&a).ok()?,
223                serde_json::to_value(&b).ok()?,
224            );
225            let mut out = String::new();
226            let lines = crate::diff::DiffView::ansi(&a.ansi, &b.ansi).style_changed_lines();
227            if !lines.is_empty() {
228                let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
229                out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
230            }
231            let field = first_difference("rows", &a_value["rows"], &b_value["rows"])
232                .or_else(|| {
233                    regions_differ
234                        .then(|| {
235                            first_difference("regions", &a_value["regions"], &b_value["regions"])
236                        })
237                        .flatten()
238                })
239                .or_else(|| first_difference("snapshot", &a_value, &b_value));
240            if let Some(field) = field {
241                out.push_str(&field);
242            }
243            return Some(out);
244        }
245        if self == other {
246            return None;
247        }
248        if self.plain != other.plain {
249            return Some(
250                crate::diff::TextDiff::new(&self.plain, &other.plain).unified("self", "other"),
251            );
252        }
253        if self.segments != other.segments {
254            let view = crate::diff::DiffView::ansi(&self.ansi, &other.ansi);
255            let lines = view.style_changed_lines();
256            let field = first_difference(
257                "segments",
258                &serde_json::to_value(&self.segments).ok()?,
259                &serde_json::to_value(&other.segments).ok()?,
260            );
261            let mut out = String::new();
262            if !lines.is_empty() {
263                let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
264                out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
265            }
266            if let Some(field) = field {
267                out.push_str(&field);
268            }
269            return (!out.is_empty()).then_some(out);
270        }
271        let a = serde_json::to_value(self).ok()?;
272        let b = serde_json::to_value(other).ok()?;
273        first_difference("snapshot", &a, &b)
274    }
275}
276fn first_difference(path: &str, a: &serde_json::Value, b: &serde_json::Value) -> Option<String> {
277    if a == b {
278        return None;
279    }
280    match (a, b) {
281        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
282            for (key, v) in a {
283                if let Some(diff) = first_difference(
284                    &format!("{path}.{key}"),
285                    v,
286                    b.get(key).unwrap_or(&serde_json::Value::Null),
287                ) {
288                    return Some(diff);
289                }
290            }
291        }
292        (serde_json::Value::Array(a), serde_json::Value::Array(b)) => {
293            for i in 0..a.len().max(b.len()) {
294                if let Some(diff) = first_difference(
295                    &format!("{path}[{i}]"),
296                    a.get(i).unwrap_or(&serde_json::Value::Null),
297                    b.get(i).unwrap_or(&serde_json::Value::Null),
298                ) {
299                    return Some(diff);
300                }
301            }
302        }
303        _ => {}
304    }
305    Some(format!("{path}: {a} -> {b}"))
306}
307/// Rows of merged runs from segments: control segments dropped, text split at
308/// line breaks, empty runs dropped, and neighbours that look the same joined.
309fn rows(segments: &[SnapshotSegment]) -> Vec<Vec<SnapshotRun>> {
310    let mut rows: Vec<Vec<SnapshotRun>> = vec![Vec::new()];
311    for segment in segments.iter().filter(|s| !s.control) {
312        for (index, piece) in segment.text.split('\n').enumerate() {
313            if index > 0 {
314                rows.push(Vec::new());
315            }
316            if piece.is_empty() {
317                continue;
318            }
319            let run = SnapshotRun {
320                text: piece.to_owned(),
321                foreground: segment.foreground.clone(),
322                background: segment.background.clone(),
323                attributes: segment.attributes.clone(),
324                link: segment.link.clone(),
325            };
326            let row = rows.last_mut().expect("at least one row");
327            match row.last_mut() {
328                Some(last) if last.same_style(&run) => last.text.push_str(&run.text),
329                _ => row.push(run),
330            }
331        }
332    }
333    rows
334}
335fn snapshot_segment(s: &Segment) -> SnapshotSegment {
336    const ATTRS: [&str; 13] = [
337        "bold",
338        "dim",
339        "italic",
340        "underline",
341        "blink",
342        "blink2",
343        "reverse",
344        "conceal",
345        "strike",
346        "underline2",
347        "frame",
348        "encircle",
349        "overline",
350    ];
351    let theme = rich::terminal_theme::DEFAULT_TERMINAL_THEME;
352    SnapshotSegment {
353        text: s.text.to_string(),
354        control: s.control,
355        foreground: s.style.as_ref().and_then(|s| s.color()).map(|c| {
356            theme
357                .resolve(c, true)
358                .hex()
359                .trim_start_matches('#')
360                .to_owned()
361        }),
362        background: s.style.as_ref().and_then(|s| s.bgcolor()).map(|c| {
363            theme
364                .resolve(c, false)
365                .hex()
366                .trim_start_matches('#')
367                .to_owned()
368        }),
369        attributes: s
370            .style
371            .as_ref()
372            .map(|s| {
373                ATTRS
374                    .iter()
375                    .enumerate()
376                    .filter_map(|(i, n)| {
377                        s.attr(i)
378                            .map(|v| if v { (*n).into() } else { format!("not {n}") })
379                    })
380                    .collect()
381            })
382            .unwrap_or_default(),
383        link: s.style.as_ref().and_then(|s| s.link()).map(str::to_owned),
384    }
385}