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    pub fn upgrade(&self) -> Self {
164        if self.rows.is_some() {
165            return self.clone();
166        }
167        Self {
168            schema_version: 2,
169            segments: Vec::new(),
170            rows: Some(rows(&self.segments)),
171            ..self.clone()
172        }
173    }
174    pub fn capture(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
175        let segments = target.segments(renderable);
176        let caps = target.capabilities();
177        Self {
178            schema_version: 1,
179            width: caps.width,
180            height: caps.height,
181            plain: segments
182                .iter()
183                .filter(|s| !s.control)
184                .map(|s| s.text.as_str())
185                .collect(),
186            ansi: target.console().segments_to_string(&segments),
187            segments: segments.iter().map(snapshot_segment).collect(),
188            rows: None,
189            regions: None,
190        }
191    }
192    pub fn to_json(&self) -> Result<String, SnapshotError> {
193        serde_json::to_string_pretty(self)
194    }
195    /// How `other` differs, or `None` when equal, without hiding style-only
196    /// changes: a `diff -u` of the plain text when it differs, else the
197    /// style-changed lines and the first differing segment field, else the
198    /// first differing metadata path.
199    ///
200    /// When either snapshot is schema 2 or 3, both are compared as schema 2:
201    /// size, plain text and rows, not `ansi` or how the text was segmented;
202    /// and regions too when both have them.
203    pub fn diff(&self, other: &Self) -> Option<String> {
204        if self.rows.is_some() || other.rows.is_some() {
205            let (a, b) = (self.upgrade(), other.upgrade());
206            let regions_differ = matches!(
207                (&a.regions, &b.regions),
208                (Some(x), Some(y)) if x != y
209            );
210            if (a.width, a.height, &a.plain, &a.rows) == (b.width, b.height, &b.plain, &b.rows)
211                && !regions_differ
212            {
213                return None;
214            }
215            if a.plain != b.plain {
216                return Some(
217                    crate::diff::TextDiff::new(&a.plain, &b.plain).unified("self", "other"),
218                );
219            }
220            let (a_value, b_value) = (
221                serde_json::to_value(&a).ok()?,
222                serde_json::to_value(&b).ok()?,
223            );
224            let mut out = String::new();
225            let lines = crate::diff::DiffView::ansi(&a.ansi, &b.ansi).style_changed_lines();
226            if !lines.is_empty() {
227                let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
228                out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
229            }
230            let field = first_difference("rows", &a_value["rows"], &b_value["rows"])
231                .or_else(|| {
232                    regions_differ
233                        .then(|| {
234                            first_difference("regions", &a_value["regions"], &b_value["regions"])
235                        })
236                        .flatten()
237                })
238                .or_else(|| first_difference("snapshot", &a_value, &b_value));
239            if let Some(field) = field {
240                out.push_str(&field);
241            }
242            return Some(out);
243        }
244        if self == other {
245            return None;
246        }
247        if self.plain != other.plain {
248            return Some(
249                crate::diff::TextDiff::new(&self.plain, &other.plain).unified("self", "other"),
250            );
251        }
252        if self.segments != other.segments {
253            let view = crate::diff::DiffView::ansi(&self.ansi, &other.ansi);
254            let lines = view.style_changed_lines();
255            let field = first_difference(
256                "segments",
257                &serde_json::to_value(&self.segments).ok()?,
258                &serde_json::to_value(&other.segments).ok()?,
259            );
260            let mut out = String::new();
261            if !lines.is_empty() {
262                let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
263                out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
264            }
265            if let Some(field) = field {
266                out.push_str(&field);
267            }
268            return (!out.is_empty()).then_some(out);
269        }
270        let a = serde_json::to_value(self).ok()?;
271        let b = serde_json::to_value(other).ok()?;
272        first_difference("snapshot", &a, &b)
273    }
274}
275fn first_difference(path: &str, a: &serde_json::Value, b: &serde_json::Value) -> Option<String> {
276    if a == b {
277        return None;
278    }
279    match (a, b) {
280        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
281            for (key, v) in a {
282                if let Some(diff) = first_difference(
283                    &format!("{path}.{key}"),
284                    v,
285                    b.get(key).unwrap_or(&serde_json::Value::Null),
286                ) {
287                    return Some(diff);
288                }
289            }
290        }
291        (serde_json::Value::Array(a), serde_json::Value::Array(b)) => {
292            for i in 0..a.len().max(b.len()) {
293                if let Some(diff) = first_difference(
294                    &format!("{path}[{i}]"),
295                    a.get(i).unwrap_or(&serde_json::Value::Null),
296                    b.get(i).unwrap_or(&serde_json::Value::Null),
297                ) {
298                    return Some(diff);
299                }
300            }
301        }
302        _ => {}
303    }
304    Some(format!("{path}: {a} -> {b}"))
305}
306/// Rows of merged runs from segments: control segments dropped, text split at
307/// line breaks, empty runs dropped, and neighbours that look the same joined.
308fn rows(segments: &[SnapshotSegment]) -> Vec<Vec<SnapshotRun>> {
309    let mut rows: Vec<Vec<SnapshotRun>> = vec![Vec::new()];
310    for segment in segments.iter().filter(|s| !s.control) {
311        for (index, piece) in segment.text.split('\n').enumerate() {
312            if index > 0 {
313                rows.push(Vec::new());
314            }
315            if piece.is_empty() {
316                continue;
317            }
318            let run = SnapshotRun {
319                text: piece.to_owned(),
320                foreground: segment.foreground.clone(),
321                background: segment.background.clone(),
322                attributes: segment.attributes.clone(),
323                link: segment.link.clone(),
324            };
325            let row = rows.last_mut().expect("at least one row");
326            match row.last_mut() {
327                Some(last) if last.same_style(&run) => last.text.push_str(&run.text),
328                _ => row.push(run),
329            }
330        }
331    }
332    rows
333}
334fn snapshot_segment(s: &Segment) -> SnapshotSegment {
335    const ATTRS: [&str; 13] = [
336        "bold",
337        "dim",
338        "italic",
339        "underline",
340        "blink",
341        "blink2",
342        "reverse",
343        "conceal",
344        "strike",
345        "underline2",
346        "frame",
347        "encircle",
348        "overline",
349    ];
350    let theme = rich::terminal_theme::DEFAULT_TERMINAL_THEME;
351    SnapshotSegment {
352        text: s.text.clone(),
353        control: s.control,
354        foreground: s.style.as_ref().and_then(|s| s.color()).map(|c| {
355            theme
356                .resolve(c, true)
357                .hex()
358                .trim_start_matches('#')
359                .to_owned()
360        }),
361        background: s.style.as_ref().and_then(|s| s.bgcolor()).map(|c| {
362            theme
363                .resolve(c, false)
364                .hex()
365                .trim_start_matches('#')
366                .to_owned()
367        }),
368        attributes: s
369            .style
370            .as_ref()
371            .map(|s| {
372                ATTRS
373                    .iter()
374                    .enumerate()
375                    .filter_map(|(i, n)| {
376                        s.attr(i)
377                            .map(|v| if v { (*n).into() } else { format!("not {n}") })
378                    })
379                    .collect()
380            })
381            .unwrap_or_default(),
382        link: s.style.as_ref().and_then(|s| s.link()).map(str::to_owned),
383    }
384}